diff --git a/.gitignore b/.gitignore index 0f29f786c0..01a29301d1 100644 --- a/.gitignore +++ b/.gitignore @@ -161,3 +161,5 @@ app_streamlit.py pixi.lock panel/_version.py .uicoveragerc +mario_button.* +counter_button.* diff --git a/doc/conf.py b/doc/conf.py index 448fc6da51..2b3f2f4a09 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -117,12 +117,14 @@ 'galleries': { 'reference': { 'title': 'Component Gallery', + 'extensions': ['*.ipynb', '*.py', '*.md'], 'sections': [ 'panes', 'widgets', 'layouts', # 3 most important by expected usage. Rest alphabetically 'chat', + 'custom_components', 'global', 'indicators', 'templates', diff --git a/doc/how_to/custom_components/esm/callbacks.md b/doc/how_to/custom_components/esm/callbacks.md new file mode 100644 index 0000000000..cf5247f90d --- /dev/null +++ b/doc/how_to/custom_components/esm/callbacks.md @@ -0,0 +1,72 @@ +# ESM component with callback + +In this guide we will show you how to add callbacks to your ESM components. + +## Slideshow with Python callback + +This example shows you how to create a `SlideShow` component that uses a Python *callback* function to update the `Slideshow` image when its clicked: + +```{pyodide} +import param +import panel as pn + +from panel.custom import JSComponent + +pn.extension() + +class Slideshow(JSComponent): + + index = param.Integer(default=0) + + _esm = """ + export function render({ model }) { + const img = document.createElement('img') + img.src = `https://picsum.photos/800/300?image=${model.index}` + img.addEventListener('click', (event) => model.send_event('click', event)) + model.on('index', () => { + img.src = `https://picsum.photos/800/300?image=${model.index}` + }) + return img + } + """ + + def _handle_click(self, event): + self.index += 1 + +Slideshow(width=500, height=200).servable() +``` + +This approach lets you quickly build custom components with complex interactivity. However if you do not need any complex computations in Python you can also construct a pure JS equivalent: + +## Slideshow with Javascript Callback + +This example shows you how to create a `Slideshow` component that uses a Javascript *callback* function to update the `Slideshow` image when its clicked: + +```{pyodide} +import param +import panel as pn + +from panel.custom import JSComponent + +pn.extension() + +class JSSlideshow(JSComponent): + + index = param.Integer(default=0) + + _esm = """ + export function render({ model }) { + const img = document.createElement('img') + img.src = `https://picsum.photos/800/300?image=${model.index}` + img.addEventListener('click', (event) => { model.index += 1 }) + model.on('index', () => { + img.src = `https://picsum.photos/800/300?image=${model.index}` + }) + return img + } + """ + +JSSlideshow(width=800, height=300).servable() +``` + +By using Javascript callbacks instead of Python callbacks you can achieve higher performance, components that can be *js linked* and components that will also work when your app is saved to static html. diff --git a/doc/how_to/custom_components/esm/custom_layout.md b/doc/how_to/custom_components/esm/custom_layout.md new file mode 100644 index 0000000000..112148636a --- /dev/null +++ b/doc/how_to/custom_components/esm/custom_layout.md @@ -0,0 +1,382 @@ +# Create Custom Layouts + +In this guide we will show you how to build custom, reusable layouts using `Viewer`, `JSComponent` or `ReactComponent`. + +## Layout a single Panel Component + +You can layout a single `object` as follows. + +::::{tab-set} + +:::{tab-item} `Viewer` + +```{pyodide} +import panel as pn +from panel.custom import Child +from panel.viewable import Viewer, Layoutable + +pn.extension() + + +class SingleObjectLayout(Viewer, Layoutable): + object = Child(allow_refs=False) + + def __init__(self, **params): + super().__init__(**params) + + header = """ +# Temperature +## A Measurement from the Sensor + """ + + layoutable_params = {name: self.param[name] for name in Layoutable.param} + self._layout = pn.Column( + pn.pane.Markdown(header, height=100, sizing_mode="stretch_width"), + self._object, + **layoutable_params, + ) + + def __panel__(self): + return self._layout + + @pn.depends("object") + def _object(self): + return self.object + + +dial = pn.widgets.Dial( + name="°C", + value=37, + format="{value}", + colors=[(0.40, "green"), (1, "red")], + bounds=(0, 100), +) +py_layout = SingleObjectLayout( + object=dial, + name="Temperature", + styles={"border": "2px solid lightgray"}, + sizing_mode="stretch_width", +) +py_layout.servable() +``` + +::: + +:::{tab-item} `JSComponent` + +```{pyodide} +import panel as pn +from panel.custom import JSComponent, Child + +pn.extension() + +class SingleObjectLayout(JSComponent): + object = Child(allow_refs=False) + + _esm = """ +export function render({ model }) { + const containerID = `id-${crypto.randomUUID()}`;; + const div = document.createElement("div"); + div.innerHTML = ` +
+

Temperature

+

A measurement from the sensor

+
...
+
`; + const container = div.querySelector(`#${containerID}`); + container.appendChild(model.get_child("object")) + return div; +} +""" + +dial = pn.widgets.Dial( + name="°C", + value=37, + format="{value}", + colors=[(0.40, "green"), (1, "red")], + bounds=(0, 100), +) +js_layout = SingleObjectLayout( + object=dial, + name="Temperature", + styles={"border": "2px solid lightgray"}, + sizing_mode="stretch_width", +) +js_layout.servable() +``` + +::: + +:::{tab-item} `ReactComponent` + +```{pyodide} +import panel as pn + +from panel.custom import Child, ReactComponent + +pn.extension() + +class SingleObjectLayout(ReactComponent): + object = Child(allow_refs=False) + + _esm = """ +export function render({ model }) { + return ( +
+

Temperature

+

A measurement from the sensor

+
+ {model.get_child("object")} +
+
+ ); +} +""" + +dial = pn.widgets.Dial( + name="°C", + value=37, + format="{value}", + colors=[(0.40, "green"), (1, "red")], + bounds=(0, 100), +) +react_layout = SingleObjectLayout( + object=dial, + name="Temperature", + styles={"border": "2px solid lightgray"}, + sizing_mode="stretch_width", +) +react_layout.servable() +``` + +::: + +:::: + +Lets verify the layout will automatically update when the `object` is changed. + +::::{tab-set} + +:::{tab-item} `Viewer` + +```{pyodide} +html = pn.pane.Markdown("A **markdown** pane!", name="Markdown") +radio_button_group = pn.widgets.RadioButtonGroup( + options=["Dial", "Markdown"], + value="Dial", + name="Select the object to display", + button_type="success", button_style="outline" +) + +@pn.depends(radio_button_group, watch=True) +def update(value): + if value == "Dial": + py_layout.object = dial + else: + py_layout.object = html + +radio_button_group.servable() +``` + +::: + +:::{tab-item} `JSComponent` + +```{pyodide} +html = pn.pane.Markdown("A **markdown** pane!", name="Markdown") +radio_button_group = pn.widgets.RadioButtonGroup( + options=["Dial", "Markdown"], + value="Dial", + name="Select the object to display", + button_type="success", button_style="outline" +) + +@pn.depends(radio_button_group, watch=True) +def update(value): + if value == "Dial": + js_layout.object = dial + else: + js_layout.object = html + +radio_button_group.servable() +``` + +::: + +:::{tab-item} `ReactComponent` + +```{pyodide} +html = pn.pane.Markdown("A **markdown** pane!", name="Markdown") +radio_button_group = pn.widgets.RadioButtonGroup( + options=["Dial", "Markdown"], + value="Dial", + name="Select the object to display", + button_type="success", button_style="outline" +) + +@pn.depends(radio_button_group, watch=True) +def update(value): + if value == "Dial": + react_layout.object = dial + else: + react_layout.object = html + +radio_button_group.servable() +``` + +::: + +:::: + +## Layout a List of Objects + +A Panel `Column` or `Row` works as a list of objects. It is *list-like*. In this section will show you how to create your own *list-like* layout using Panels `NamedListLike` class. + +::::{tab-set} + +:::{tab-item} `Viewer` + +```{pyodide} +import panel as pn +from panel.viewable import Viewer, Layoutable +from panel.custom import Children +from panel.layout.base import NamedListLike + +pn.extension() + + +class ListLikeLayout(NamedListLike, Layoutable, Viewer): + objects = Children() + + def __init__(self, *args, **params): + super().__init__(*args, **params) + + layoutable_params = {name: self.param[name] for name in Layoutable.param} + self._layout = pn.Column( + **layoutable_params, + ) + self._objects() + + def __panel__(self): + return self._layout + + @pn.depends("objects", watch=True) + def _objects(self): + objects = [] + for object in self.objects: + objects.append(object) + objects.append( + pn.pane.HTML( + styles={"width": "calc(100% - 15px)", "border-top": "3px dotted #bbb"}, + height=10, + ) + ) + + self._layout[:] = objects + + +ListLikeLayout( + "I love beat boxing", + "https://upload.wikimedia.org/wikipedia/commons/d/d3/Beatboxset1_pepouni.ogg", + "Yes I do!", + styles={"border": "2px solid lightgray"}, +).servable() +``` + +You must list `NamedListLike, Layoutable, Viewer` in exactly that order when you define the class! Other combinations might not work. + +::: + +:::{tab-item} `JSComponent` + +```{pyodide} +import panel as pn +import param +from panel.custom import JSComponent +from panel.layout.base import NamedListLike + +pn.extension() + + +class ListLikeLayout(NamedListLike, JSComponent): + objects = param.List() + + _esm = """ + export function render({ model }) { + const div = document.createElement('div') + let objects = model.get_child("objects") + + objects.forEach((object, index) => { + div.appendChild(object); + + // If it's not the last object, add a divider + if (index < objects.length - 1) { + const divider = document.createElement("div"); + divider.className = "divider"; + div.appendChild(divider); + } + }); + return div + }""" + + _stylesheets = [ + """ +.divider {border-top: 3px dotted #bbb}; +""" + ] + + +ListLikeLayout( + "I love beat boxing", + "https://upload.wikimedia.org/wikipedia/commons/d/d3/Beatboxset1_pepouni.ogg", + "Yes I do!", + styles={"border": "2px solid lightgray"}, +).servable() +``` + +You must list `NamedListLike, JSComponent` in exactly that order when you define the class! The other +way around `JSComponent, NamedListLike` will not work. + +::: + +:::{tab-item} `ReactComponent` + +```{pyodide} +import panel as pn + +from panel.custom import Children, ReactComponent + +class Example(ReactComponent): + + objects = Children() + + _esm = """ + export function render({ model }) { + let objects = model.get_child("objects") + return ( +
+ {objects.map((object, index) => ( + + {object} + {index < objects.length - 1 &&
} +
+ ))} +
+ ); + }""" + + +Example( + objects=[pn.panel("A **Markdown** pane!"), pn.widgets.Button(name="Click me!"), {"text": "I'm shown as a JSON Pane"}] +).servable() +``` + +::: + +:::: + +:::{note} +You must list `ListLike, ReactComponent` in exactly that order when you define the class! The other way around `ReactComponent, ListLike` will not work. +::: + +You can now use `[...]` indexing and the `.append`, `.insert`, `pop`, ... methods that you would expect. diff --git a/doc/how_to/custom_components/esm/custom_widgets.md b/doc/how_to/custom_components/esm/custom_widgets.md new file mode 100644 index 0000000000..eccee4ded1 --- /dev/null +++ b/doc/how_to/custom_components/esm/custom_widgets.md @@ -0,0 +1,236 @@ +# Custom Widgets + +In this guide we will show you how to efficiently implement custom widgets using `JSComponent`, `ReactComponent` and `AnyWidgetComponent` to get input from the user. + +## Image Button + +This example we will show you to create an `ImageButton`. + +::::{tab-set} + +:::{tab-item} `JSComponent` + +```{pyodide} +import panel as pn +import param + +from panel.custom import JSComponent +from panel.widgets import WidgetBase + +pn.extension() + +class ImageButton(JSComponent, WidgetBase): + + clicks = param.Integer(default=0) + image = param.String() + value = param.Event() + + _esm = """ +export function render({ model }) { + const button = document.createElement('button'); + button.id = 'button'; + button.className = 'pn-container center-content'; + + const img = document.createElement('img'); + img.id = 'image'; + img.className = 'image-size'; + img.src = model.image; + + button.appendChild(img); + + button.addEventListener('click', () => { + model.clicks += 1; + }); + return button +} +""" + + _stylesheets = [""" +.pn-container { + height: 100%; + width: 100%; +} +.center-content { + display: flex; + align-items: center; + justify-content: center; + padding: 1em; +} +.image-size { + width: 100%; + max-height: 100%; + object-fit: contain; +} +"""] + + @param.depends('clicks') + def _trigger_value(self): + self.param.trigger('value') + +button = ImageButton( + image="https://panel.holoviz.org/_static/logo_stacked.png", + styles={"border": "2px solid lightgray"}, + width=400, height=200 +) +pn.Column(button, button.param.clicks,).servable() +``` + +::: + +:::{tab-item} `ReactComponent` + +```pyodide +import panel as pn +import param + +from panel.custom import ReactComponent +from panel.widgets import WidgetBase + +pn.extension() + +class ImageButton(ReactComponent, WidgetBase): + + clicks = param.Integer(default=0) + image = param.String() + value = param.Event() + + _esm = """ +export function render({ model }) { + const [clicks, setClicks] = model.useState("clicks"); + const [image] = model.useState("image"); + + return ( + + ) +} +""" + + _stylesheets = [""" +.pn-container { + height: 100%; + width: 100%; +} +.center-content { + display: flex; + align-items: center; + justify-content: center; + padding: 1em; +} +.image-size { + width: 100%; + max-height: 100%; + object-fit: contain; +} +"""] + + @param.depends('clicks') + def _trigger_value(self): + self.param.trigger('value') + +button = ImageButton( + image="https://panel.holoviz.org/_static/logo_stacked.png", + styles={"border": "2px solid lightgray"}, + width=400, height=200 +) +pn.Column(button, button.param.clicks).servable() +``` + +::: + +:::{tab-item} `AnyWidgetComponent` + +```{pyodide} +import panel as pn +import param + +from panel.custom import AnyWidgetComponent + +pn.extension() + +class ImageButton(AnyWidgetComponent, WidgetBase): + + clicks = param.Integer(default=0) + image = param.String() + value = param.Event() + + _esm = """ +function render({ model, el }) { + const button = document.createElement('button'); + button.id = 'button'; + button.className = 'pn-container center-content'; + + const img = document.createElement('img'); + img.id = 'image'; + img.className = 'image-size'; + img.src = model.get("image"); + + button.appendChild(img); + + button.addEventListener('click', () => { + model.set("clicks", model.get("clicks")+1); + model.save_changes(); + }); + el.appendChild(button); +} +export default { render } +""" + + _stylesheets = [""" +.pn-container { + height: 100%; + width: 100%; +} +.center-content { + display: flex; + align-items: center; + justify-content: center; + padding: 1em; +} +.image-size { + width: 100%; + max-height: 100%; + object-fit: contain; +} +"""] + + @param.depends('clicks') + def _trigger_value(self): + self.param.trigger('value') + +button = ImageButton( + image="https://panel.holoviz.org/_static/logo_stacked.png", + styles={"border": "2px solid lightgray"}, + width=400, height=200 +) + +pn.Column(button, button.param.clicks).servable() +``` + +::: + +:::: + +If you don't want the *button* styling, you can change the ` + ) + } + """ + +class Rating(MaterialComponent): + + value = param.Number(default=0, bounds=(0, 5)) + + _esm = """ + import Rating from '@mui/material/Rating' + + export function render({model}) { + const [value, setValue] = model.useState("value") + return ( + setValue(newValue) } + /> + ) + } + """ + +class DiscreteSlider(MaterialComponent): + + marks = param.List(default=[ + {'value': 0, 'label': '0°C'}, + {'value': 20, 'label': '20°C'}, + {'value': 37, 'label': '37°C'}, + {'value': 100, 'label': '100°C'}, + ]) + + value = param.Number(default=20) + + _esm = """ + import Box from '@mui/material/Box'; + import Slider from '@mui/material/Slider'; + + export function render({ model }) { + const [value, setValue] = model.useState("value") + const [marks] = model.useState("marks") + return ( + + setValue(e.target.value)} + step={null} + valueLabelDisplay="auto" + /> + + ); + } + """ + +button = Button() +rating = Rating(value=3) +slider = DiscreteSlider() + +pn.Row( + pn.Column(button.controls(['disabled', 'label', 'variant']), button), + pn.Column(rating.controls(['value']), rating), + pn.Column(slider.controls(['value']), slider), +).servable() +``` diff --git a/doc/how_to/custom_components/index.md b/doc/how_to/custom_components/index.md index a3d6c3f36b..95e5626b89 100644 --- a/doc/how_to/custom_components/index.md +++ b/doc/how_to/custom_components/index.md @@ -18,12 +18,120 @@ How to build custom components that are combinations of existing components. :::: +### Examples + +::::{grid} 1 2 2 3 +:gutter: 1 1 1 2 + +:::{grid-item-card} Build a Plot Viewer +:img-top: https://assets.holoviz.org/panel/how_to/custom_components/plot_viewer.png +:link: examples/plot_viewer +:link-type: doc + +Build a custom component wrapping a bokeh plot and some widgets using the `Viewer` pattern. +::: + +:::{grid-item-card} Build a Table Viewer +:img-top: https://assets.holoviz.org/panel/how_to/custom_components/table_viewer.png +:link: examples/table_viewer +:link-type: doc + +Build a custom component wrapping a table and some widgets using the `Viewer` pattern. +::: + +:::: + ```{toctree} :titlesonly: :hidden: :maxdepth: 2 custom_viewer +examples/plot_viewer +examples/table_viewer +``` + +## ESM Components + +Build custom components in Javascript using so called ESM components, which allow you to write components that automatically sync parameter state between Python and JS. ESM components can be written in pure JS, using React or using the AnyWidget specification. + +::::{grid} 1 2 2 3 +:gutter: 1 1 1 2 + +:::{grid-item-card} {octicon}`pencil;2.5em;sd-mr-1 sd-animate-grow50` Add callbacks to ESM components +:link: esm/callbacks +:link-type: doc + +How to add both JS and Python based callbacks to ESM components. +::: + +:::{grid-item-card} {octicon}`pencil;2.5em;sd-mr-1 sd-animate-grow50` Create Custom Widgets +:link: esm/custom_widgets +:link-type: doc + +How to create a custom widget using ESM components +::: + +:::{grid-item-card} {octicon}`columns;2.5em;sd-mr-1 sd-animate-grow50` Create Custom Layouts +:link: esm/custom_layout +:link-type: doc + +How to create a custom layout using ESM components +::: + +:::{grid-item-card} {octicon}`table;2.5em;sd-mr-1 sd-animate-grow50` Render a `DataFrame` +:link: esm/dataframe +:link-type: doc + +How to create `JSComponent`s and `ReactComponent`s that render data in a DataFrame. +::: + +:::: + +### Examples + +::::{grid} 1 2 2 3 +:gutter: 1 1 1 2 + +:::{grid-item-card} Canvas `JSComponent` +:img-top: https://assets.holoviz.org/panel/how_to/custom_components/canvas_draw.png +:link: examples/esm_canvas +:link-type: doc + +Build a custom component to draw on an HTML canvas based on `JSComponent`. +::: + +:::{grid-item-card} Leaflet.js `JSComponent` +:img-top: https://assets.holoviz.org/panel/how_to/custom_components/leaflet.png +:link: examples/esm_leaflet +:link-type: doc + +Build a custom component wrapping leaflet.js using `JSComponent`. +::: + +:::{grid-item-card} Material UI `ReactComponent` +:img-top: https://assets.holoviz.org/panel/how_to/custom_components/material_ui.png +:link: examples/esm_material_ui +:link-type: doc + +Build custom components wrapping Material UI using `ReactComponent`. +::: + +:::: + +```{toctree} +:titlesonly: +:hidden: +:maxdepth: 2 + +esm/callbacks +esm/custom_widgets +esm/custom_layout +esm/dataframe +examples/esm_canvas +examples/esm_leaflet +examples/esm_material_ui + ``` ## `ReactiveHTML` Components @@ -84,41 +192,11 @@ How to create components using `ReactiveHTML` and a DataFrame parameter :::: -```{toctree} -:titlesonly: -:hidden: -:maxdepth: 2 - -reactive_html/reactive_html_layout -reactive_html/reactive_html_styling -reactive_html/reactive_html_panes -reactive_html/reactive_html_indicators -reactive_html/reactive_html_callbacks -reactive_html/reactive_html_widgets -reactive_html/reactive_html_dataframe -``` - -## Examples +### Examples ::::{grid} 1 2 2 3 :gutter: 1 1 1 2 -:::{grid-item-card} Build a Plot Viewer -:img-top: https://assets.holoviz.org/panel/how_to/custom_components/plot_viewer.png -:link: examples/plot_viewer -:link-type: doc - -Build a custom component wrapping a bokeh plot and some widgets using the `Viewer` pattern. -::: - -:::{grid-item-card} Build a Table Viewer -:img-top: https://assets.holoviz.org/panel/how_to/custom_components/table_viewer.png -:link: examples/table_viewer -:link-type: doc - -Build a custom component wrapping a table and some widgets using the `Viewer` pattern. -::: - :::{grid-item-card} Build a Canvas component :img-top: https://assets.holoviz.org/panel/how_to/custom_components/canvas_draw.png :link: examples/canvas_draw @@ -158,8 +236,13 @@ Build custom component wrapping a Vue.js app using `ReactiveHTML`. :hidden: :maxdepth: 2 -examples/plot_viewer -examples/table_viewer +reactive_html/reactive_html_layout +reactive_html/reactive_html_styling +reactive_html/reactive_html_panes +reactive_html/reactive_html_indicators +reactive_html/reactive_html_callbacks +reactive_html/reactive_html_widgets +reactive_html/reactive_html_dataframe examples/canvas_draw examples/leaflet examples/material_ui diff --git a/doc/how_to/custom_components/reactive_html/reactive_html_callbacks.md b/doc/how_to/custom_components/reactive_html/reactive_html_callbacks.md index 579874dede..8a637146f9 100644 --- a/doc/how_to/custom_components/reactive_html/reactive_html_callbacks.md +++ b/doc/how_to/custom_components/reactive_html/reactive_html_callbacks.md @@ -4,8 +4,7 @@ In this guide we will show you how to add callbacks to your `ReactiveHTML` compo ## Slideshow with Python callback -This example shows you how to create a `SlideShow` component that uses a Python *callback* -function to update the `SlideShow` image when its clicked: +This example shows you how to create a `Slideshow` component that uses a Python *callback* function to update the `Slideshow` image when its clicked: ```{pyodide} import param @@ -24,16 +23,14 @@ class Slideshow(ReactiveHTML): def _img_click(self, event): self.index += 1 -Slideshow(width=500, height=200).servable() +Slideshow().servable() ``` -This approach lets you quickly build custom HTML components with complex interactivity. -However if you do not need any complex computations in Python you can also construct a pure JS equivalent: +This approach lets you quickly build custom HTML components with complex interactivity. However if you do not need any complex computations in Python you can also construct a pure JS equivalent: ## Slideshow with Javascript Callback -This example shows you how to create a `SlideShow` component that uses a Javascript *callback* -function to update the `SlideShow` image when its clicked: +This example shows you how to create a `SlideShow` component that uses a Javascript *callback* function to update the `SlideShow` image when its clicked: ```{pyodide} import param @@ -51,9 +48,7 @@ class JSSlideshow(ReactiveHTML): _scripts = {'click': 'data.index += 1'} -JSSlideshow(width=800, height=300).servable() +JSSlideshow().servable() ``` -By using Javascript callbacks instead of Python callbacks you can achieve higher performance, -components that can be *js linked* and components that will also work when your app is saved to -static html. +By using Javascript callbacks instead of Python callbacks you can achieve higher performance, components that can be *js linked* and components that will also work when your app is saved to static html. diff --git a/doc/how_to/custom_components/reactive_html/reactive_html_dataframe.md b/doc/how_to/custom_components/reactive_html/reactive_html_dataframe.md index afbcfc701c..9ad84869e5 100644 --- a/doc/how_to/custom_components/reactive_html/reactive_html_dataframe.md +++ b/doc/how_to/custom_components/reactive_html/reactive_html_dataframe.md @@ -1,12 +1,10 @@ # DataFrames and ReactiveHTML -In this guide we will show you how to implement `ReactiveHTML` components with a -`DataFrame` parameter. +In this guide we will show you how to implement `ReactiveHTML` components with a `DataFrame` parameter. ## Creating a Custom DataFrame Pane -In this example we will show you how to create a custom DataFrame Pane. The example will be based on -the [GridJS table](https://gridjs.io/). +In this example we will show you how to create a custom DataFrame Pane. The example will be based on the [GridJS table](https://gridjs.io/). ```{pyodide} import random @@ -14,8 +12,10 @@ import pandas as pd import param import panel as pn +from panel.custom import ReactiveHTML + +class GridJS(ReactiveHTML): -class GridJS(pn.reactive.ReactiveHTML): value = param.DataFrame() _template = '
' @@ -24,25 +24,24 @@ class GridJS(pn.reactive.ReactiveHTML): _scripts = { "render": """ -console.log(data.value) -state.config= ()=>{ - const columns = Object.keys(data.value).filter(key => key !== "index"); - var rows= [] - for (let index=0;index{ - return data.value[key][index] - }) - rows.push(row) - } - return {columns: columns, data: rows, resizable: true, sort: true } -} -config = state.config() -console.log(config) -state.grid = new gridjs.Grid(config).render(wrapper); -""", "value": """ -config = state.config() -state.grid.updateConfig(config).forceRender() -""" + console.log(data.value) + state.config = () => { + const columns = Object.keys(data.value).filter(key => key !== "index"); + const rows = [] + for (let index=0; index < data.value["index"].shape[0]; index++) { + const row = columns.map(key => data.value[key][index]) + rows.push(row) + } + return {columns: columns, data: rows, resizable: true, sort: true} + } + const config = state.config() + console.log(config) + state.grid = new gridjs.Grid(config).render(wrapper); + """, + "value": """ + config = state.config() + state.grid.updateConfig(config).forceRender() + """ } __css__ = [ @@ -53,6 +52,7 @@ state.grid.updateConfig(config).forceRender() "https://unpkg.com/gridjs/dist/gridjs.umd.js" ] + def data(event): return pd.DataFrame([ ["John", "john@example.com", "(353) 01 222 3333", random.uniform(0, 1)], @@ -60,18 +60,17 @@ def data(event): ["Eoin", "eoin@gmail.com", "0097 22 654 00033", random.uniform(0, 1)], ["Sarah", "sarahcdd@gmail.com", "+322 876 1233", random.uniform(0, 1)], ["Afshin", "afshin@mail.com", "(353) 22 87 8356", random.uniform(0, 1)] - ], - columns= ["Name", "Email", "Phone Number", "Random"] - ) + ], columns= ["Name", "Email", "Phone Number", "Random"]) + update_button = pn.widgets.Button(name="UPDATE", button_type="primary") + grid = GridJS(value=pn.bind(data, update_button), sizing_mode="stretch_width") + pn.Column(update_button, grid).servable() ``` -The main challenge of creating this component is understanding the structure of `data.value` and -how it can be converted to a format (`config`) that `gridjs.Grid` accepts. +The main challenge of creating this component is understanding the structure of `data.value` and how it can be converted to a format (`config`) that `gridjs.Grid` accepts. -To help you understand what the `data.value` and `config` values looks like, I've logged them to -the *browser console* using `console.log`. +To help you understand what the `data.value` and `config` values looks like, I've logged them to the *browser console* using `console.log`. ![DataFrame in the console](../../../_static/reactive-html-dataframe-in-console.png) diff --git a/doc/how_to/custom_components/reactive_html/reactive_html_layout.md b/doc/how_to/custom_components/reactive_html/reactive_html_layout.md index a5f518378f..cabe791a24 100644 --- a/doc/how_to/custom_components/reactive_html/reactive_html_layout.md +++ b/doc/how_to/custom_components/reactive_html/reactive_html_layout.md @@ -10,16 +10,19 @@ You can layout a single object as follows. import panel as pn import param +from panel.custom import Child, ReactiveHTML + pn.extension() -class LayoutSingleObject(pn.reactive.ReactiveHTML): - object = param.Parameter(allow_refs=False) +class LayoutSingleObject(ReactiveHTML): + + object = Child(allow_refs=False) _template = """
-

Temperature

-

A measurement from the sensor

-
${object}
+

Temperature

+

A measurement from the sensor

+
${object}
""" @@ -39,18 +42,11 @@ LayoutSingleObject( ``` :::{note} - -Please note - - We define the HTML layout in the `_template` attribute. - We can refer to the parameter `object` in the `_template` via the *template parameter* `${object}`. - - We must give the `div` element holding the `${object}` an `id`. If we do not, then an exception - will be raised. The `id` can be any value, for example `id="my-object"`. -- We call our *object* parameter `object` to be consistent with our built in layouts. But the -parameter can be called anything. For example `value`, `dial` or `temperature`. -- We add the `border` in the `styles` parameter so that we can better see how the `_template` layes -out inside the `ReactiveHTML` component. This can be very useful for development. - + - We must give the `div` element holding the `${object}` an `id`. If we do not, then an exception will be raised. The `id` can be any value, for example `id="my-object"`. +- We call our *object* parameter `object` to be consistent with our built in layouts. But the parameter can be called anything. For example `value`, `dial` or `temperature`. +- We add the `border` in the `styles` parameter so that we can better see how the `_template` layes out inside the `ReactiveHTML` component. This can be very useful for development. ::: ## Layout multiple parameters @@ -59,11 +55,13 @@ out inside the `ReactiveHTML` component. This can be very useful for development import panel as pn import param +from panel.custom import Child, ReactiveHTML + pn.extension() -class LayoutMultipleValues(pn.reactive.ReactiveHTML): - object1 = param.Parameter(allow_refs=False) - object2 = param.Parameter(allow_refs=False) +class LayoutMultipleValues(ReactiveHTML): + object1 = Child() + object2 = Child() _template = """
@@ -84,9 +82,7 @@ layout.servable() You might notice that the values of `object1` and `object2` looks like they have been rendered as markdown! That is correct. -Before inserting the value of a parameter in the -`_template`, Panel transforms the value using `pn.panel`. And for a string value `pn.panel` returns -a `Markdown` pane. +Before inserting the value of a parameter in the `_template`, Panel transforms the value using `pn.panel`. And for a string value `pn.panel` returns a `Markdown` pane. Let's verify this. @@ -106,32 +102,33 @@ LayoutMultipleValues( ## Layout as literal `str` values -If you want to show the *literal* `str` value of your parameter instead of the `pn.panel` return -value you can configure that via the `_child_config` attribute. +If you want to show the *literal* `str` value of your parameter instead of the `pn.panel` return value you can configure that via the `_child_config` attribute. ```{pyodide} import panel as pn import param +from panel.custom import ReactiveHTML + pn.extension() -class LayoutLiteralValues(pn.reactive.ReactiveHTML): - object1 = param.Parameter() - object2 = param.Parameter() +class LayoutLiteralValues(ReactiveHTML): + object1 = param.String() + object2 = param.String() _child_config = {"object1": "literal", "object2": "literal"} _template = """
-

Object 1

-
${object1}
-

Object 2

-
${object2}
+

Object 1

+
${object1}
+

Object 2

+
${object2}
-""" + """ layout = LayoutLiteralValues( object1="This is the **value** of `object1`", object2="This is the **value** of `object2`", @@ -154,10 +151,13 @@ If you want to want to layout a dynamic `List` of objects you can use a *for loo import panel as pn import param +from panel.custom import Children, ReactiveHTML + pn.extension() -class LayoutOfList(pn.reactive.ReactiveHTML): - objects = param.List() +class LayoutOfList(ReactiveHTML): + + objects = Children() _template = """
@@ -170,11 +170,10 @@ class LayoutOfList(pn.reactive.ReactiveHTML): """ LayoutOfList(objects=[ - "I **love** beat boxing", - "https://upload.wikimedia.org/wikipedia/commons/d/d3/Beatboxset1_pepouni.ogg", - "Yes I do!"], - styles={"border": "2px solid lightgray"}, -).servable() + "I **love** beat boxing", + "https://upload.wikimedia.org/wikipedia/commons/d/d3/Beatboxset1_pepouni.ogg", + "Yes I do!" +], styles={"border": "2px solid lightgray"}).servable() ``` The component will trigger a rerendering if you update the `List` value. @@ -196,25 +195,27 @@ You can optionally ## Create a list like layout -If you want to create a *list like* layout similar to `Column` and `Row`, you can -combine `NamedListLike` and `ReactiveHTML`. +If you want to create a *list like* layout similar to `Column` and `Row`, you can combine `ListLike` and `ReactiveHTML`. ```{pyodide} import panel as pn import param +from panel.custom import ReactiveHTML +from panel.layout.base import ListLike + pn.extension() -class ListLikeLayout(pn.layout.base.NamedListLike, pn.reactive.ReactiveHTML): +class ListLikeLayout(ListLike, ReactiveHTML): objects = param.List() _template = """
- {% for object in objects %} -

Object {{ loop.index0 }}

-
${object}
-
- {% endfor %} + {% for object in objects %} +

Object {{ loop.index0 }}

+
${object}
+
+ {% endfor %}
""" @@ -232,7 +233,7 @@ expect. :::{note} -You must list `NamedListLike, ReactiveHTML` in exactly that order when you define the class! The other +You must list `ListLike, ReactiveHTML` in exactly that order when you define the class! The other way around `ReactiveHTML, NamedListLike` will not work. :::: @@ -245,37 +246,35 @@ If you want to layout a dictionary, you can use a for loop on the `.items()`. import panel as pn import param +from panel.custom import ReactiveHTML + pn.extension() -class LayoutOfDict(pn.reactive.ReactiveHTML): +class LayoutOfDict(ReactiveHTML): object = param.Dict() _template = """
- {% for key, value in object.items() %} -

{{ loop.index0 }}. {{ key }}

-
${value}
-
- {% endfor %} + {% for key, value in object.items() %} +

{{ loop.index0 }}. {{ key }}

+
${value}
+
+ {% endfor %}
-""" + """ LayoutOfDict(object={ "Intro": "I **love** beat boxing", "Example": "https://upload.wikimedia.org/wikipedia/commons/d/d3/Beatboxset1_pepouni.ogg", "*Outro*": "Yes I do!" -}, - styles={"border": "2px solid lightgray"}, -).servable() +}, styles={"border": "2px solid lightgray"}).servable() ``` :::{note} Please note -- We can insert the `key` as a literal value only using `{{ key }}`. Inserting it as a template -variable `${key}` will not work. -- We must not give the HTML element containing `{{ key }}` an `id`. If we do, an exception will be -raised. +- We can insert the `key` as a literal value only using `{{ key }}`. Inserting it as a template variable `${key}` will not work. +- We must not give the HTML element containing `{{ key }}` an `id`. If we do, an exception will be raised. ::: diff --git a/doc/how_to/custom_components/reactive_html/reactive_html_panes.md b/doc/how_to/custom_components/reactive_html/reactive_html_panes.md index b3828ddc0a..272d845bd4 100644 --- a/doc/how_to/custom_components/reactive_html/reactive_html_panes.md +++ b/doc/how_to/custom_components/reactive_html/reactive_html_panes.md @@ -4,32 +4,34 @@ In this guide we will show you how to efficiently implement `ReactiveHTML` panes ## Creating a ChartJS Pane -This example will show you the basics of creating a -[ChartJS](https://www.chartjs.org/docs/latest/) pane. +This example will show you the basics of creating a [ChartJS](https://www.chartjs.org/docs/latest/) pane. ```{pyodide} import panel as pn import param +from panel.custom import PaneBase, ReactiveHTML + +class ChatJSComponent(ReactiveHTML): -class ChatJSComponent(pn.reactive.ReactiveHTML): object = param.Dict() - _template = ( - '
' - ) + _template = """ +
+ """ + _scripts = { - "after_layout": """ - self.object() -""", + "after_layout": "if (state.chart == null) { self.object() }", + "remove": """ + state.chart.destroy(); + state.chart = null; + """, "object": """ -if (state.chart){ - state.chart.destroy(); - state.chart = null; -} -state.chart = new Chart(canvas_el.getContext('2d'), data.object); -""", + if (state.chart) { self.remove() } + state.chart = new Chart(canvas_el.getContext('2d'), data.object); + """, } + __javascript__ = [ "https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js" ] @@ -65,11 +67,7 @@ grid = ChatJSComponent( pn.Column(chart_type, grid).servable() ``` -Initially I tried creating the chart in the `render` function, but the chart did not display. -I found out via Google Search and experimentation that the chart needs to be created later in the -`after_layout` function. If you get stuck -share your question and minimum, reproducible code example on -[Discourse](https://discourse.holoviz.org/). +Note that the chart is not created inside the `after_layout` callback since ChartJS requires the layout to be fully initialized before render. Dealing with layout issues like this sometimes requires a bit of iteration, if you get stuck, share your question and minimum, reproducible code example on [Discourse](https://discourse.holoviz.org/). ## Creating a Cytoscape Pane @@ -78,9 +76,12 @@ This example will show you how to build a more advanced [CytoscapeJS](https://js ```{pyodide} import param import panel as pn -from panel.reactive import ReactiveHTML + +from panel.custom import ReactiveHTML + class Cytoscape(ReactiveHTML): + object = param.List() layout = param.Selector(default="cose", objects=["breadthfirst", "circle", "concentric", "cose", "grid", "preset", "random"]) @@ -94,44 +95,38 @@ class Cytoscape(ReactiveHTML): selected_nodes = param.List() selected_edges = param.List() - _template = """ -
- """ + _template = '
' + __javascript__ = ['https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.23.0/cytoscape.umd.js'] _scripts = { - 'render': """ - self.create() - """, - "create": """ - if (state.cy == undefined){ - state.cy = cytoscape({ - container: cy, - layout: {name: data.layout}, - elements: data.object, - zoom: data.zoom, - pan: data.pan, - }); - state.cy.on('select unselect', function (evt) { - data.selected_nodes = state.cy.elements('node:selected').map((el)=>{return el.id()}) - data.selected_edges = state.cy.elements('edge:selected').map((el)=>{return el.id()}) - }); - self.style() - const mainEle = document.querySelector("body") - mainEle.addEventListener("scrollend", (event) => {state.cy.resize().fit()}) - }; + "render": """ + if (state.cy == undefined) { + state.cy = cytoscape({ + container: cy, + layout: {name: data.layout}, + elements: data.object, + zoom: data.zoom, + pan: data.pan, + }); + state.cy.on('select unselect', function (evt) { + data.selected_nodes = state.cy.elements('node:selected').map(el => el.id()) + data.selected_edges = state.cy.elements('edge:selected').map(el => el.id()) + }); + self.style() + const mainEle = document.querySelector("body") + mainEle.addEventListener("scrollend", (event) => {state.cy.resize().fit()}) + }; """, - 'remove': """ - state.cy.destroy() - delete state.cy + "remove": """ + state.cy.destroy() + delete state.cy """, "object": "state.cy.json({elements: data.object});state.cy.resize().fit()", - 'layout': "state.cy.layout({name: data.layout}).run()", + "layout": "state.cy.layout({name: data.layout}).run()", "zoom": "state.cy.zoom(data.zoom)", "pan": "state.cy.pan(data.pan)", - "style": """ -state.cy.style().resetToDefault().append(data.style).update() -""", + "style": "state.cy.style().resetToDefault().append(data.style).update()", } _extension_name = 'cytoscape' @@ -146,9 +141,6 @@ pn.Row( ).servable() ``` -Please notice that we `resize` and `fit` the graph on `scrollend`. -This is a *hack* needed to make the graph show up and fit nicely to the screen. +Please notice that we `resize` and `fit` the graph on `scrollend`. This is a *hack* needed to make the graph show up and fit nicely to the screen. -Hacks like these are sometimes needed and requires a bit of experience to find. If you get stuck -share your question and minimum, reproducible code example on -[Discourse](https://discourse.holoviz.org/). +Hacks like these are sometimes needed and requires a bit of experience to find. If you get stuck share your question and minimum, reproducible code example on [Discourse](https://discourse.holoviz.org/). diff --git a/doc/how_to/custom_components/reactive_html/reactive_html_styling.md b/doc/how_to/custom_components/reactive_html/reactive_html_styling.md index 9fb8339c55..d9d0d52647 100644 --- a/doc/how_to/custom_components/reactive_html/reactive_html_styling.md +++ b/doc/how_to/custom_components/reactive_html/reactive_html_styling.md @@ -11,12 +11,13 @@ an HTML and CSS starting point that you can fine tune. ```{pyodide} import param import panel as pn -from panel.reactive import ReactiveHTML + +from panel.custom import Child, ReactiveHTML pn.extension() class SensorLayout(ReactiveHTML): - object = param.Parameter(allow_refs=False) + object = Child(allow_refs=False) _template = """
diff --git a/doc/how_to/custom_components/reactive_html/reactive_html_widgets.md b/doc/how_to/custom_components/reactive_html/reactive_html_widgets.md index 527d447a6c..78e2adf9d8 100644 --- a/doc/how_to/custom_components/reactive_html/reactive_html_widgets.md +++ b/doc/how_to/custom_components/reactive_html/reactive_html_widgets.md @@ -10,15 +10,18 @@ This example we will show you to create an `ImageButton`. import panel as pn import param -from panel.reactive import ReactiveHTML +from panel.custom import ReactiveHTML, WidgetBase pn.extension() -class ImageButton(ReactiveHTML): +class ImageButton(ReactiveHTML, WidgetBase): clicks = param.Integer(default=0) + image = param.String() + value = param.Event() + _template = """\ + ); + } + """ + +# Display the component +CounterButton().servable() +``` + +### Mario Button + +Check out our [Custom Components Tutorial](../../../tutorials/expert/custom_components.md) to see a converted version of the [ipymario](https://github.com/manzt/ipymario) widget. + +[![Mario Button](https://assets.holoviz.org/panel/tutorials/ipymario.gif)](../../../tutorials/expert/custom_components.md) + +## References + +### Tutorials + +- [Build Custom Components](../../../how_to/custom_components/reactive_esm/reactive_esm_layout.md) + +### How-To Guides + +- [Convert `AnyWidget` widgets](../../../how_to/migrate/anywidget/index.md) + +### Reference Guides + +- [`JSComponent`](../../../reference/panes/JSComponent.md) +- [`ReactComponent`](../../../reference/panes/ReactComponent.md) +- [`PreactComponent`](../../../reference/panes/PreactComponent.md) + +With these skills, you are now equipped to pioneer and push the boundaries of what can be achieved with Panel. Happy coding! diff --git a/doc/how_to/migrate_to_panel.md b/doc/how_to/migrate_to_panel.md index f6190c1cf7..bfab3c8623 100644 --- a/doc/how_to/migrate_to_panel.md +++ b/doc/how_to/migrate_to_panel.md @@ -10,5 +10,6 @@ :hidden: :maxdepth: 1 +Migrate from AnyWidget Migrate from Streamlit ``` diff --git a/doc/how_to/wasm/sphinx.md b/doc/how_to/wasm/sphinx.md index 734019adec..8e11c4d9ab 100644 --- a/doc/how_to/wasm/sphinx.md +++ b/doc/how_to/wasm/sphinx.md @@ -7,22 +7,23 @@ One more option is to include live Panel examples in your Sphinx documentation u In the near future we hope to make this a separate Sphinx extension, until then simply install latest nbsite with `pip` or `conda`: ::::{tab-set} + :::{tab-item} Pip :sync: pip ``` bash pip install nbsite ``` - ::: + :::{tab-item} Conda :sync: conda ``` bash conda install -c pyviz nbsite ``` - ::: + :::: add the extension to the Sphinx `conf.py`: diff --git a/doc/tutorials/expert/custom_components.md b/doc/tutorials/expert/custom_components.md new file mode 100644 index 0000000000..c27f93f0d1 --- /dev/null +++ b/doc/tutorials/expert/custom_components.md @@ -0,0 +1,245 @@ +# Creating a `MarioButton` with `JSComponent` + +In this tutorial we will build a *[Mario](https://mario.nintendo.com/) style button* with sounds and animations using the [`JSComponent`](../../reference/custom/JSComponent.md) feature in Panel. It aims to help you learn how to push the boundaries of what can be achieved with HoloViz Panel by creating advanced components using modern JavaScript and CSS technologies. + +![Mario chime button](https://assets.holoviz.org/panel/tutorials/ipymario.gif) + +This tutorial draws heavily on the great [`ipymario` tutorial](https://youtu.be/oZhyilx3gqI?si=dFPFiHua4TuuqCpu) by [Trevor Manzt](https://github.com/manzt). + +## Overview + +We'll build a `MarioButton` that displays a pixelated Mario icon and plays a chime sound when clicked. The button will also have customizable parameters for gain, duration, size, and animation, showcasing the powerful capabilities of `JSComponent`. + +### Prerequisites + +Ensure you have HoloViz Panel installed: + +```sh +pip install panel watchfiles +``` + +## Step 1: Define the `MarioButton` Component + +We'll start by defining the Python class for the `MarioButton` component, including its parameters and rendering logic. + +Create a file named `mario_button.py`: + +```python +import numpy as np +import param +from panel.custom import JSComponent +import panel as pn + +colors = { + "O": [0, 0, 0, 255], + "X": [247, 82, 0, 255], + " ": [247, 186, 119, 255], +} + +# fmt: off +box = [ + ['O', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'O'], + ['X', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'O'], + ['X', ' ', 'O', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'O', ' ', 'O'], + ['X', ' ', ' ', ' ', ' ', 'X', 'X', 'X', 'X', 'X', ' ', ' ', ' ', ' ', ' ', 'O'], + ['X', ' ', ' ', ' ', 'X', 'X', 'O', 'O', 'O', 'X', 'X', ' ', ' ', ' ', ' ', 'O'], + ['X', ' ', ' ', ' ', 'X', 'X', 'O', ' ', ' ', 'X', 'X', 'O', ' ', ' ', ' ', 'O'], + ['X', ' ', ' ', ' ', 'X', 'X', 'O', ' ', ' ', 'X', 'X', 'O', ' ', ' ', ' ', 'O'], + ['X', ' ', ' ', ' ', ' ', 'O', 'O', ' ', 'X', 'X', 'X', 'O', ' ', ' ', ' ', 'O'], + ['X', ' ', ' ', ' ', ' ', ' ', ' ', 'X', 'X', 'O', 'O', 'O', ' ', ' ', ' ', 'O'], + ['X', ' ', ' ', ' ', ' ', ' ', ' ', 'X', 'X', 'O', ' ', ' ', ' ', ' ', ' ', 'O'], + ['X', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'O', 'O', ' ', ' ', ' ', ' ', ' ', 'O'], + ['X', ' ', ' ', ' ', ' ', ' ', ' ', 'X', 'X', ' ', ' ', ' ', ' ', ' ', ' ', 'O'], + ['X', ' ', ' ', ' ', ' ', ' ', ' ', 'X', 'X', 'O', ' ', ' ', ' ', ' ', ' ', 'O'], + ['X', ' ', 'O', ' ', ' ', ' ', ' ', ' ', 'O', 'O', ' ', ' ', ' ', 'O', ' ', 'O'], + ['X', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'O'], + ['O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O'], +] +# fmt: on + +np_box = np.array([[colors[c] for c in row] for row in box], dtype=np.uint8) +np_box_as_list = [[[int(z) for z in y] for y in x] for x in np_box.tolist()] + +class MarioButton(JSComponent): + + _esm = "mario_button.js" + _stylesheets = ["mario_button.css"] + + _box = param.List(np_box_as_list) + gain = param.Number(0.1, bounds=(0.1, 1.0), step=0.1) + duration = param.Number(1.0, bounds=(0.5, 2), step=0.5,) + size = param.Integer(100, bounds=(10, 1000), step=10) + animate = param.Boolean(True) + + margin = param.Integer(10) + +if pn.state.served: + button = MarioButton() + parameters = pn.Param( + button, parameters=["gain", "duration", "size", "animate"] + ) + settings=pn.Column(parameters, "Credits: Trevor Mantz") + pn.FlexBox(settings, button).servable() +``` + +### Explanation - Python + +- **`_esm`**: Specifies the path to the JavaScript file for the component. +- **`_stylesheets`**: Specifies the path to the CSS file for styling the component. +- **`_box`**: A parameter representing the pixel data for the Mario icon. +- **`gain`, `duration`, `size`, `animate`**: Parameters for customizing the button's behavior. +- **`pn.Param`**: Creates a Panel widget to control the parameters. + +## Step 2: Define the JavaScript for the `MarioButton` + +Create a file named `mario_button.js`: + +```javascript +/** + * Plays a Mario chime sound with the specified gain and duration. + * @see {@link https://twitter.com/mbostock/status/1765222176641437859} + */ +function chime({ gain, duration }) { + let c = new AudioContext(); + let g = c.createGain(); + let o = c.createOscillator(); + let of = o.frequency; + g.connect(c.destination); + g.gain.value = gain; + g.gain.linearRampToValueAtTime(0, duration); + o.connect(g); + o.type = "square"; + of.setValueAtTime(988, 0); + of.setValueAtTime(1319, 0.08); + o.start(); + o.stop(duration); +} + +function createCanvas(data) { + let size = () => `${data.size}px`; + let canvas = document.createElement("canvas"); + canvas.width = 16; + canvas.height = 16; + canvas.style.width = size(); + canvas.style.height = size(); + return canvas; +} + +function drawImageData(canvas, pixelData) { + const flattenedData = pixelData.flat(2); + const imageDataArray = new Uint8ClampedArray(flattenedData); + const imgData = new ImageData(imageDataArray, 16, 16); + + let ctx = canvas.getContext("2d"); + ctx.imageSmoothingEnabled = false; + ctx.putImageData(imgData, 0, 0); +} + +function addClickListener(canvas, data) { + canvas.addEventListener("click", () => { + chime({ + gain: data.gain, + duration: data.duration, + }); + if (data.animate) { + canvas.style.animation = "none"; + setTimeout(() => { + canvas.style.animation = "ipymario-bounce 0.2s"; + }, 10); + } + }); +} + +function addResizeWatcher(canvas, data) { + data.on('size', () => { + let size = () => `${data.size}px`; + canvas.style.width = size(); + canvas.style.height = size(); + console.log("resized"); + }); +} + +export function render({ data, el }) { + let canvas = createCanvas(data); + drawImageData(canvas, data._box); + addClickListener(canvas, data); + addResizeWatcher(canvas, data); + + el.classList.add("ipymario"); + return canvas; +} +``` + +### Explanation - JavaScript + +- **`chime`**: A function that generates the Mario chime sound using the Web Audio API. +- **`render`**: The main function that renders the button, sets up the canvas, handles click events, and manages parameter changes. + +## Step 3: Define the CSS for the `MarioButton` + +Create a file named `mario_button.css`: + +```css +.ipymario > canvas { + animation-fill-mode: both; + image-rendering: pixelated; /* Ensures the image stays pixelated */ + image-rendering: crisp-edges; /* For additional support in some browsers */ +} + +@keyframes ipymario-bounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-12px); } +} +``` + +### Explanation - CSS + +- **`.ipymario > canvas`**: Styles the canvas to ensure the Mario icon remains pixelated. +- **`@keyframes ipymario-bounce`**: Defines the bounce animation for the button when clicked. + +## Step 4: Serve the Application + +To serve the application, run the following command in your terminal: + +```sh +panel serve mario_button.py --autoreload +``` + +This command will start a Panel server and automatically reload changes as you edit the files. + +The result should look like this: + + + +You'll have to turn on the sound to hear the chime. + +## Step 4: Develop the Application with Autoreload + +When you save your `.py`, `.js` or `.css` file, the Panel server will automatically reload the changes. This feature is called *auto reload* or *hot reload*. + +Try changing `"ipymario-bounce 0.2s"` in the `mario_button.js` file to `"ipymario-bounce 2s"` and save the file. The Panel server will automatically reload the changes. + +Try clicking the button to see the button bounce more slowly. + +## Conclusion + +You've now created a custom `MarioButton` component using [`JSComponent`](../../reference/panes/JSComponent.md) in HoloViz Panel. This button features a pixelated Mario icon, plays a chime sound when clicked, and has customizable parameters for gain, duration, size, and animation. + +## References + +### Tutorials + +- [Build Custom Components](../../how_to/custom_components/reactive_esm/reactive_esm_layout.md) + +### How-To Guides + +- [Convert `AnyWidget` widgets](../../how_to/migrate/anywidget/index.md) + +### Reference Guides + +- [`JSComponent`](../../reference/panes/JSComponent.md) +- [`ReactComponent`](../../reference/panes/ReactComponent.md) +- [`PreactComponent`](../../reference/panes/PreactComponent.md) diff --git a/doc/tutorials/expert/index.md b/doc/tutorials/expert/index.md index ef0b0d571a..baa1680585 100644 --- a/doc/tutorials/expert/index.md +++ b/doc/tutorials/expert/index.md @@ -8,6 +8,14 @@ We will assume you have an *intermediate skill level* corresponding to the what ## Extend Panel -- **Develop Custom Components**: Use `ReactiveESM` or `ReactiveHTML` to build advanced components utilizing JavaScript. +- **[Develop Custom Components](./custom_components.md)**: Use `JSComponent` to build advanced components utilizing modern JavaScript and CSS technologies. - **Develop Custom Bokeh models**: - **Customizing Panel for your brand**: + +```{toctree} +:titlesonly: +:hidden: +:maxdepth: 2 + +custom_components +``` diff --git a/doc/tutorials/intermediate/reusable_components.md b/doc/tutorials/intermediate/reusable_components.md index aae3bc4a92..470746115e 100644 --- a/doc/tutorials/intermediate/reusable_components.md +++ b/doc/tutorials/intermediate/reusable_components.md @@ -251,6 +251,10 @@ We should now be able to write reusable `Parameterized` and `Viewer` classes tha ## Resources +### Reference Guide + +- [`Viewer`](../../reference/custom_components/Viewer.md) + ### How-To - [Combine Existing Components](../../how_to/custom_components/custom_viewer.ipynb) diff --git a/examples/how_to/custom/js/child.py b/examples/how_to/custom/js/child.py new file mode 100644 index 0000000000..4b54bb0089 --- /dev/null +++ b/examples/how_to/custom/js/child.py @@ -0,0 +1,13 @@ +from panel.custom import Child, JSComponent + + +class Example(JSComponent): + + child = Child() + + _esm = """ + export function render({ model }) { + return model.get_child('child') + }""" + +Example(child='A Markdown pane!').servable() diff --git a/examples/how_to/custom/js/children.py b/examples/how_to/custom/js/children.py new file mode 100644 index 0000000000..9642e181e1 --- /dev/null +++ b/examples/how_to/custom/js/children.py @@ -0,0 +1,18 @@ +from panel.custom import Children, JSComponent + + +class Example(JSComponent): + + children = Children() + + _esm = """ + export function render({ model }) { + const div = document.createElement('div') + div.append(...model.get_child('children')) + return div + }""" + +Example(children=[ + 'A Markdown pane!', + 'Another Markdown pane!' +]).servable() diff --git a/examples/how_to/custom/js/confetti.py b/examples/how_to/custom/js/confetti.py new file mode 100644 index 0000000000..7f0b04e485 --- /dev/null +++ b/examples/how_to/custom/js/confetti.py @@ -0,0 +1,28 @@ +import panel as pn + +from panel.custom import JSComponent + +pn.extension() + +class ConfettiButton(JSComponent): + + _importmap = { + "imports": { + "confetti": "https://esm.sh/canvas-confetti@1.6.0", + } + } + + _esm = """ + import confetti from "confetti"; + + export function render() { + let btn = document.createElement("button"); + btn.innerHTML = `Click Me`; + btn.addEventListener("click", () => { + confetti() + }); + return btn + } + """ + +ConfettiButton().servable() diff --git a/examples/how_to/custom/js/slideshow.py b/examples/how_to/custom/js/slideshow.py new file mode 100644 index 0000000000..d1eb33e517 --- /dev/null +++ b/examples/how_to/custom/js/slideshow.py @@ -0,0 +1,51 @@ +import param + +import panel as pn + +from panel.custom import JSComponent + +pn.extension() + +class JSSlideshow(JSComponent): + + index = param.Integer(default=0) + + _esm = """ + export function render({ model }) { + const img = document.createElement('img') + img.addEventListener('click', () => { model.index += 1 }) + const update = () => { + img.src = `https://picsum.photos/800/300?image=${model.index}` + } + model.watch(update, 'index') + update() + return img + } + """ + + +class PySlideshow(JSComponent): + + index = param.Integer(default=0) + + _esm = """ + export function render({ model }) { + const img = document.createElement('img') + img.addEventListener('click', (event) => model.send_event('click', event)) + const update = () => { + img.src = `https://picsum.photos/800/300?image=${model.index}` + } + model.watch(update, 'index') + update() + return img + } + """ + + def _handle_click(self, event): + self.index += 1 + + +pn.Row( + JSSlideshow(), + PySlideshow() +).servable() diff --git a/examples/how_to/custom/react/child.py b/examples/how_to/custom/react/child.py new file mode 100644 index 0000000000..6d1f6be873 --- /dev/null +++ b/examples/how_to/custom/react/child.py @@ -0,0 +1,13 @@ +from panel.custom import Child, ReactComponent + + +class Example(ReactComponent): + + child = Child() + + _esm = """ + export function render({ model }) { + return + }""" + +Example(child='A Markdown pane!').servable() diff --git a/examples/how_to/custom/react/children.py b/examples/how_to/custom/react/children.py new file mode 100644 index 0000000000..470e2f807b --- /dev/null +++ b/examples/how_to/custom/react/children.py @@ -0,0 +1,16 @@ +from panel.custom import Children, ReactComponent + + +class Example(ReactComponent): + + children = Children() + + _esm = """ + export function render({ model }) { + return
{model.get_child("children")}
+ }""" + +Example(children=[ + 'A Markdown pane!', + 'Another Markdown pane!' +]).servable() diff --git a/examples/how_to/custom/react/material_ui.py b/examples/how_to/custom/react/material_ui.py new file mode 100644 index 0000000000..262ac7ee29 --- /dev/null +++ b/examples/how_to/custom/react/material_ui.py @@ -0,0 +1,58 @@ +import pathlib + +import param + +import panel as pn + +from panel.custom import ReactComponent + + +class MuiComponent(ReactComponent): + + _importmap = { + "imports": { + "@mui/material/": "https://esm.sh/@mui/material@5.15.16/", + } + } + +class Button(MuiComponent): + + label = param.String() + + variant = param.Selector(default='contained', objects=['text', 'contained', 'outlined']) + + _esm = 'mui_button.js' + +class DiscreteSlider(MuiComponent): + + marks = param.List(default=[]) + + value = param.Number(default=20) + + _esm = 'mui_slider.js' + + +b = Button() +s = DiscreteSlider(marks=[ + { + 'value': 0, + 'label': '0°C', + }, + { + 'value': 20, + 'label': '20°C', + }, + { + 'value': 37, + 'label': '37°C', + }, + { + 'value': 100, + 'label': '100°C', + }, +]) + +pn.Row( + pn.Param(b.param, parameters=['label', 'variant']), + pn.Column(b, s) +).servable() diff --git a/examples/how_to/custom/react/mui_button.js b/examples/how_to/custom/react/mui_button.js new file mode 100644 index 0000000000..660a945042 --- /dev/null +++ b/examples/how_to/custom/react/mui_button.js @@ -0,0 +1,9 @@ +import Button from '@mui/material/Button?deps=react@18.2.0'; + +export function MuiButton({ model }) { + const [label] = model.useState("label") + const [variant] = model.useState("variant") + return +} + +export default { render: MuiButton } diff --git a/examples/how_to/custom/react/mui_slider.js b/examples/how_to/custom/react/mui_slider.js new file mode 100644 index 0000000000..7961f0735b --- /dev/null +++ b/examples/how_to/custom/react/mui_slider.js @@ -0,0 +1,20 @@ +import Box from '@mui/material/Box?deps=react@18.2.0'; +import Slider from '@mui/material/Slider?deps=react@18.2.0'; + +function DiscreteSlider({ model }) { + const [value, setValue] = model.useState("value") + const [marks] = model.useState("marks") + return ( + + + + ); +} + +export default { render: DiscreteSlider } diff --git a/examples/how_to/custom/react/react_demo.js b/examples/how_to/custom/react/react_demo.js new file mode 100644 index 0000000000..87ebb0e544 --- /dev/null +++ b/examples/how_to/custom/react/react_demo.js @@ -0,0 +1,29 @@ +import confetti from "canvas-confetti"; +import Button from '@mui/material/Button?deps=react@18.2.0&no-bundle'; + +function App(props) { + const [color, setColor] = props.state.color + const [text, setText ] = props.state.text + const [celebrate, setCelebrate] = props.state.celebrate + + React.useEffect(() => confetti(), [celebrate]) + const style = {color: color} + return ( + <> +

{text}

+ {props.child} + setText(e.target.value)} + /> + + + + ); +} + +export function render({ state, el, children, view }) { + return ( + + ) +} diff --git a/examples/how_to/custom/react/react_import_demo.py b/examples/how_to/custom/react/react_import_demo.py new file mode 100644 index 0000000000..9a9c1fc9cf --- /dev/null +++ b/examples/how_to/custom/react/react_import_demo.py @@ -0,0 +1,40 @@ +import param + +import panel as pn + +from panel.custom import Child, ReactComponent + + +class Example(ReactComponent): + + child = Child() + + child2 = Child() + + color = param.Color() + + text = param.String() + + celebrate = param.Boolean() + + _esm = 'react_demo.js' + + _importmap = { + "imports": { + "@emotion/cache": "https://esm.sh/@emotion/cache", + "@emotion/react": "https://esm.sh/@emotion/react@11.11.4", + "@mui/material/": "https://esm.sh/@mui/material@5.11.10/", + "canvas-confetti": "https://esm.sh/canvas-confetti@1.6.0", + }, + "scopes": { + } + } + +example = Example(text='Hello World!', child='Wow!') + +button = pn.widgets.Button(on_click=lambda e: example.param.update(child='Woo!'), name='Update') + +pn.Row( + pn.Param(example.param, parameters=['color', 'text', 'celebrate']), + example, button +).servable() diff --git a/examples/how_to/custom/react/slideshow.py b/examples/how_to/custom/react/slideshow.py new file mode 100644 index 0000000000..1020917066 --- /dev/null +++ b/examples/how_to/custom/react/slideshow.py @@ -0,0 +1,41 @@ +import param + +import panel as pn + +from panel.custom import ReactComponent + +pn.extension() + +class JSSlideshow(ReactComponent): + + index = param.Integer(default=0) + + _esm = """ + export function render({ model }) { + const [index, setIndex] = model.useState("index") + const img = `https://picsum.photos/800/300?image=${index}` + return { setIndex(index+1) } }> + } + """ + + +class PySlideshow(ReactComponent): + + index = param.Integer(default=0) + + _esm = """ + export function render({ model }) { + const [index, setIndex] = model.useState("index") + const img = `https://picsum.photos/800/300?image=${index}` + return model.send_event('click', event) }> + } + """ + + def _handle_click(self, event): + self.index += 1 + + +pn.Row( + JSSlideshow(), + PySlideshow() +).servable() diff --git a/examples/reference/custom_components/AnyWidget.md b/examples/reference/custom_components/AnyWidget.md new file mode 100644 index 0000000000..ad371b87bb --- /dev/null +++ b/examples/reference/custom_components/AnyWidget.md @@ -0,0 +1,310 @@ +# `AnyWidgetComponent` + +Panel's `AnyWidgetComponent` class simplifies the creation of custom Panel components using the [`AnyWidget`](https://anywidget.dev/) JavaScript API. + +```pyodide +import panel as pn +import param + +from panel.custom import AnyWidgetComponent + +pn.extension() + +class CounterButton(AnyWidgetComponent): + + value = param.Integer() + + _esm = """ + function render({ model, el }) { + let count = () => model.get("value"); + let btn = document.createElement("button"); + btn.innerHTML = `count is ${count()}`; + btn.addEventListener("click", () => { + model.set("value", count() + 1); + model.save_changes(); + }); + model.on("change:value", () => { + btn.innerHTML = `count is ${count()}`; + }); + el.appendChild(btn); + } + export default { render }; + """ + +CounterButton().servable() +``` + +:::{note} +Panel's `AnyWidgetComponent` supports using the [`AnyWidget`](https://anywidget.dev/) API on the JavaScript side and the [`param`](https://param.holoviz.org/) parameters API on the Python side. + +If you are looking to create custom components using Python and Panel component only, check out [`Viewer`](Viewer.md). + +::: + +## API + +### AnyWidgetComponent Attributes + +- **`_esm`** (str | PurePath): This attribute accepts either a string or a path that points to an [ECMAScript module](https://nodejs.org/api/esm.html#modules-ecmascript-modules). The ECMAScript module should export a `default` object or function that returns an object. The object should contain a `render` function and optionally an `initialize` function. In a development environment such as a notebook or when using `--autoreload`, the module will automatically reload upon saving changes. +- **`_importmap`** (dict | None): This optional dictionary defines an [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap), allowing you to customize how module specifiers are resolved. +- **`_stylesheets`** (optional list of strings): This optional attribute accepts a list of CSS strings or paths to CSS files. It supports automatic reloading in development environments. + +:::note + +You may specify a path to a file as a string instead of a PurePath. The path should be specified relative to the file it is referenced in. + +::: + +#### `render` Function + +The `_esm` `default` object must contain a `render` function. It accepts the following parameters: + +- **`model`**: Represents the parameters of the component and provides methods to `.get` values, `.set` values, and `.save_changes`. +- **`el`**: The parent HTML element to append HTML elements to. + +For more detail, see [`AnyWidget`](https://anywidget.dev/). + +## Usage + +### Styling with CSS + +Include CSS within the `_stylesheets` attribute to style the component. The CSS is injected directly into the component's HTML. + +```pyodide +import panel as pn +import param + +from panel.custom import AnyWidgetComponent + +pn.extension() + +class StyledCounterButton(AnyWidgetComponent): + + value = param.Integer() + + _esm = """ + function render({ model, el }) { + let count = () => model.get("value"); + let btn = document.createElement("button"); + btn.innerHTML = `count is ${count()}`; + btn.addEventListener("click", () => { + model.set("value", count() + 1); + model.save_changes(); + }); + model.on("change:value", () => { + btn.innerHTML = `count is ${count()}`; + }); + el.appendChild(btn); + } + export default { render }; + """ + + _stylesheets = [ + """ + button { + background: #0072B5; + color: white; + border: none; + padding: 10px; + border-radius: 4px; + } + button:hover { + background: #4099da; + } + """ + ] + +StyledCounterButton().servable() +``` + +## Dependency Imports + +JavaScript dependencies can be directly imported via URLs, such as those from [`esm.sh`](https://esm.sh/). + +```pyodide +import panel as pn +from panel.custom import AnyWidgetComponent + +pn.extension() + +class ConfettiButton(AnyWidgetComponent): + + _esm = """ + import confetti from "https://esm.sh/canvas-confetti@1.6.0"; + + function render({ el }) { + let btn = document.createElement("button"); + btn.innerHTML = "Click Me"; + btn.addEventListener("click", () => { + confetti(); + }); + el.appendChild(btn); + } + export default { render } + """ + +ConfettiButton().servable() +``` + +Use the `_import_map` attribute for more concise module references. + +```pydodide +import panel as pn +from panel.custom import AnyWidgetComponent + +pn.extension() + +class ConfettiButton(AnyWidgetComponent): + + _importmap = { + "imports": { + "canvas-confetti": "https://esm.sh/canvas-confetti@1.6.0", + } + } + + _esm = """ + import confetti from "canvas-confetti"; + + function render({ el }) { + let btn = document.createElement("button"); + btn.innerHTML = "Click Me"; + btn.addEventListener("click", () => { + confetti(); + }); + el.appendChild(btn); + } + export default { render } + """ + +ConfettiButton().servable() +``` + +See the [import map documentation](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) for more information about the import map format. + +## External Files + +You can load JavaScript and CSS from files by providing the paths to these files. + +Create the file **counter_button.py**. + +```python +from pathlib import Path + +import param +import panel as pn + +from panel.custom import AnyWidgetComponent + +pn.extension() + +class CounterButton(AnyWidgetComponent): + + value = param.Integer() + + _esm = Path("counter_button.js") + _stylesheets = [Path("counter_button.css")] + +CounterButton().servable() +``` + +Now create the file **counter_button.js**. + +```javascript +function render({ model, el }) { + let value = () => model.get("value"); + let btn = document.createElement("button"); + btn.innerHTML = `count is ${value()}`; + btn.addEventListener("click", () => { + model.set('value', value() + 1); + model.save_changes(); + }); + model.on("change:value", () => { + btn.innerHTML = `count is ${value()}`; + }); + el.appendChild(btn); +} +export default { render } +``` + +Now create the file **counter_button.css**. + +```css +button { + background: #0072B5; + color: white; + border: none; + padding: 10px; + border-radius: 4px; +} +button:hover { + background: #4099da; +} +``` + +Serve the app with `panel serve counter_button.py --autoreload`. + +You can now edit the JavaScript or CSS file, and the changes will be automatically reloaded. + +- Try changing the `innerHTML` from `count is ${value()}` to `COUNT IS ${value()}` and observe the update. Note you must update `innerHTML` in two places. +- Try changing the background color from `#0072B5` to `#008080`. + +## React + +You can use React with `AnyWidget` as shown below. + +```pydodide +import panel as pn +import param + +from panel.custom import AnyWidgetComponent + +pn.extension() + +class CounterButton(AnyWidgetComponent): + + value = param.Integer() + + _importmap = { + "imports": { + "@anywidget/react": "https://esm.sh/@anywidget/react", + "react": "https://esm.sh/react@18.2.0", + } + } + + _esm = """ + import * as React from "react"; /* mandatory import */ + import { createRender, useModelState } from "@anywidget/react"; + + const render = createRender(() => { + const [value, setValue] = useModelState("value"); + return ( + + ); + }); + export default { render } + """ + +CounterButton().servable() +``` + +:::{note} +You will notice that Panel's `AnyWidgetComponent` can be used with React and [JSX](https://react.dev/learn/writing-markup-with-jsx) without any build tools. Instead of build tools, Panel uses [Sucrase](https://sucrase.io/) to transpile the JSX code to JavaScript on the client side. +::: + +## References + +### Tutorials + +- [Build Custom Components](../../../how_to/custom_components/reactive_esm/reactive_esm_layout.md) + +### How-To Guides + +- [Convert `AnyWidget` widgets](../../../how_to/migrate/anywidget/index.md) + +### Reference Guides + +- [`AnyWidgetComponent`](../../../reference/panes/AnyWidgetComponent.md) +- [`JSComponent`](../../../reference/panes/JSComponent.md) +- [`ReactComponent`](../../../reference/panes/ReactComponent.md) diff --git a/examples/reference/custom_components/JSComponent.md b/examples/reference/custom_components/JSComponent.md new file mode 100644 index 0000000000..17353f896a --- /dev/null +++ b/examples/reference/custom_components/JSComponent.md @@ -0,0 +1,456 @@ +# `JSComponent` + +`JSComponent` simplifies the creation of custom Panel components using JavaScript. + +```pyodide +import panel as pn +import param + +from panel.custom import JSComponent + +pn.extension() + +class CounterButton(JSComponent): + + value = param.Integer() + + _esm = """ + export function render({ model }) { + let btn = document.createElement("button"); + btn.innerHTML = `count is ${model.value}`; + btn.addEventListener("click", () => { + model.value += 1 + }); + model.on('value', () => { + btn.innerHTML = `count is ${model.value}`; + }) + return btn + } + """ + +CounterButton().servable() +``` + +:::{note} +`JSComponent` was introduced in June 2024 as a successor to `ReactiveHTML`. + +`JSComponent` bears similarities to [`AnyWidget`](https://anywidget.dev/), but it is specifically optimized for use with Panel. + +If you are looking to create custom components using Python and Panel component only, check out [`Viewer`](Viewer.md). +::: + +## API + +### JSComponent Attributes + +- **`_esm`** (str | PurePath): This attribute accepts either a string or a path that points to an [ECMAScript module](https://nodejs.org/api/esm.html#modules-ecmascript-modules). The ECMAScript module should export a `render` function which returns the HTML element to display. In a development environment such as a notebook or when using `--autoreload`, the module will automatically reload upon saving changes. +- **`_importmap`** (dict | None): This optional dictionary defines an [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap), allowing you to customize how module specifiers are resolved. +- **`_stylesheets`** (optional list of strings): This optional attribute accepts a list of CSS strings or paths to CSS files. It supports automatic reloading in development environments. + +:::note + +You may specify a path to a file as a string instead of a PurePath. The path should be specified relative to the file its specified in. + +::: + +### `render` Function + +The `_esm` attribute must export the `render` function. It accepts the following parameters: + +- **`model`**: Represents the Parameters of the component and provides methods to add (and remove) event listeners using `.on` and `.off`, render child elements using `.get_child`, and to `.send_event` back to Python. +- **`view`**: The Bokeh view. +- **`el`**: The HTML element that the component will be rendered into. + +Any HTML element returned from the `render` function will be appended to the HTML element (`el`) of the component but you may also manually append to and manipulate the `el` directly. + +### Callbacks + +The `model.on` and `model.off` methods allow registering event handlers inside the render function. This includes the ability to listen to parameter changes and register lifecycle hooks. + +#### Change Events + +The following signatures are valid when listening to change events: + +- `.on('', callback)`: Allows registering an event handler for a single parameter. +- `.on(['', ...], callback)`: Allows adding an event handler for multiple parameters at once. +- `.on('change:', callback)`: The `change:` prefix allows disambiguating change events from lifecycle hooks should a parameter name and lifecycle hook overlap. + +#### Lifecycle Hooks + +- `after_render`: Called once after the component has been fully rendered. +- `after_resize`: Called after the component has been resized. +- `remove`: Called when the component view is being removed from the DOM. + +## Usage + +### Styling with CSS + +Include CSS within the `_stylesheets` attribute to style the component. The CSS is injected directly into the component's HTML. + +```pyodide +import panel as pn +import param + +from panel.custom import JSComponent + +pn.extension() + +class StyledCounterButton(JSComponent): + + value = param.Integer() + + _stylesheets = [ + """ + button { + background: #0072B5; + color: white; + border: none; + padding: 10px; + border-radius: 4px; + } + button:hover { + background: #4099da; + } + """ + ] + + _esm = """ + export function render({ model }) { + const btn = document.createElement("button"); + btn.innerHTML = `count is ${model.value}`; + btn.addEventListener("click", () => { + model.value += 1 + }); + model.on('value', () => { + btn.innerHTML = `count is ${model.value}`; + }) + return btn + } + """ + +StyledCounterButton().servable() +``` + +## Send Events from JavaScript to Python + +Events from JavaScript can be sent to Python using the `model.send_event` method. Define a *handler* in Python to manage these events. A *handler* is a method on the form `_handle_(self, event)`: + +```pyodide +import panel as pn +import param + +from panel.custom import JSComponent + +pn.extension() + +class EventExample(JSComponent): + + value = param.Parameter() + + _esm = """ + export function render({ model }) { + const btn = document.createElement('button') + btn.innerHTML = `Click Me` + btn.onclick = (event) => model.send_event('click', event) + return btn + } + """ + + def _handle_click(self, event): + self.value = str(event.__dict__) + +button = EventExample() +pn.Column( + button, pn.widgets.TextAreaInput(value=button.param.value, height=200), +).servable() +``` + +You can also define and send your own custom events: + +```pyodide +import datetime + +import panel as pn +import param + +from panel.custom import JSComponent + +pn.extension() + +class CustomEventExample(JSComponent): + + value = param.String() + + _esm = """ + export function render({ model }) { + const btn = document.createElement('button') + btn.innerHTML = `Click Me`; + btn.onclick = (event) => { + const currentDate = new Date(); + const custom_event = new CustomEvent("click", { detail: currentDate.getTime() }); + model.send_event('click', custom_event) + } + return btn + } + """ + + def _handle_click(self, event): + unix_timestamp = event.data["detail"]/1000 + python_datetime = datetime.datetime.fromtimestamp(unix_timestamp) + self.value = str(python_datetime) + +button = CustomEventExample() +pn.Column( + button, button.param.value, +).servable() +``` + +## Dependency Imports + +JavaScript dependencies can be directly imported via URLs, such as those from [`esm.sh`](https://esm.sh/). + +```pyodide +import panel as pn +from panel.custom import JSComponent + +pn.extension() + +class ConfettiButton(JSComponent): + + _esm = """ + import confetti from "https://esm.sh/canvas-confetti@1.6.0"; + + export function render() { + let btn = document.createElement("button"); + btn.innerHTML = "Click Me"; + btn.addEventListener("click", () => { + confetti() + }); + return btn + } + """ + +ConfettiButton().servable() +``` + +Use the `_importmap` attribute for more concise module references. + +```pyodide +import panel as pn + +from panel.custom import JSComponent + +pn.extension() + +class ConfettiButton(JSComponent): + _importmap = { + "imports": { + "canvas-confetti": "https://esm.sh/canvas-confetti@1.6.0", + } + } + + _esm = """ + import confetti from "canvas-confetti"; + + export function render() { + let btn = document.createElement("button"); + btn.innerHTML = `Click Me`; + btn.addEventListener("click", () => { + confetti() + }); + return btn + } + """ + +ConfettiButton().servable() +``` + +See [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) for more info about the import map format. + +## External Files + +You can load JavaScript and CSS from files by providing the paths to these files. + +Create the file **counter_button.py**. + +```python +from pathlib import Path + +import param +import panel as pn + +from panel.custom import JSComponent + +pn.extension() + +class CounterButton(JSComponent): + + value = param.Integer() + + _esm = Path("counter_button.js") + _stylesheets = [Path("counter_button.css")] + +CounterButton().servable() +``` + +Now create the file **counter_button.js**. + +```javascript +export function render({ model }) { + let btn = document.createElement("button"); + btn.innerHTML = `count is ${model.value}`; + btn.addEventListener("click", () => { + model.value += 1; + }); + model.on('value', () => { + btn.innerHTML = `count is ${model.value}`; + }); + return btn; +} +``` + +Now create the file **counter_button.css**. + +```css +button { + background: #0072B5; + color: white; + border: none; + padding: 10px; + border-radius: 4px; +} +button:hover { + background: #4099da; +} +``` + +Serve the app with `panel serve counter_button.py --autoreload`. + +You can now edit the JavaScript or CSS file, and the changes will be automatically reloaded. + +- Try changing the `innerHTML` from `count is ${model.value}` to `COUNT IS ${model.value}` and observe the update. Note you must update `innerHTML` in two places. +- Try changing the background color from `#0072B5` to `#008080`. + +## Displaying A Single Child + +You can display Panel components (`Viewable`s) by defining a `Child` parameter. + +Lets start with the simplest example: + +```pyodide +import panel as pn + +from panel.custom import Child, JSComponent + +class Example(JSComponent): + + child = Child() + + _esm = """ + export function render({ model }) { + const button = document.createElement("button"); + button.append(model.get_child("child")) + return button + }""" + +Example(child=pn.panel("A **Markdown** pane!")).servable() +``` + +If you provide a non-`Viewable` child it will automatically be converted to a `Viewable` by `pn.panel`: + +```pyodide +Example(child="A **Markdown** pane!").servable() +``` + +If you want to allow a certain type of Panel components only you can specify the specific type in the `class_` argument. + +```pyodide +import panel as pn + +from panel.custom import Child, JSComponent + +class Example(JSComponent): + + child = Child(class_=pn.pane.Markdown) + + _esm = """ + export function render({ children }) { + const button = document.createElement("button"); + button.append(model.get_child("child")) + return button + }""" + +Example(child=pn.panel("A **Markdown** pane!")).servable() +``` + +The `class_` argument also supports a tuple of types: + +```pyodide +import panel as pn + +from panel.custom import Child, JSComponent + +class Example(JSComponent): + + child = Child(class_=(pn.pane.Markdown, pn.pane.HTML)) + + _esm = """ + export function render({ children }) { + const button = document.createElement("button"); + button.append(model.get_child("child")) + return button + }""" + +Example(child=pn.panel("A **Markdown** pane!")).servable() +``` + +## Displaying a List of Children + +You can also display a `List` of `Viewable` objects using the `Children` parameter type: + +```pyodide +import panel as pn + +from panel.custom import Children, JSComponent + +pn.extension() + +class Example(JSComponent): + + objects = Children() + + _esm = """ + export function render({ model }) { + const div = document.createElement('div') + div.append(...model.get_child("objects")) + return div + }""" + + +Example( + objects=[pn.panel("A **Markdown** pane!"), pn.widgets.Button(name="Click me!"), {"text": "I'm shown as a JSON Pane"}] +).servable() +``` + +:::note + +You can change the `item_type` to a specific subtype of `Viewable` or a tuple of +`Viewable` subtypes. + +::: + +## References + +### Tutorials + +- [Build Custom Components](../../../how_to/custom_components/reactive_esm/reactive_esm_layout.md) + +### How-To Guides + +- [Convert `AnyWidget` widgets](../../../how_to/migrate/anywidget/index.md) + +### Reference Guides + +- [`AnyWidgetComponent`](../../../reference/panes/AnyWidgetComponent.md) +- [`JSComponent`](../../../reference/panes/JSComponent.md) +- [`ReactComponent`](../../../reference/panes/ReactComponent.md) diff --git a/examples/reference/custom_components/ReactComponent.md b/examples/reference/custom_components/ReactComponent.md new file mode 100644 index 0000000000..8a10cc994c --- /dev/null +++ b/examples/reference/custom_components/ReactComponent.md @@ -0,0 +1,483 @@ +# `ReactComponent` + +`ReactComponent` simplifies the creation of custom Panel components by allowing you to write standard [React](https://react.dev/) code without the need to pre-compile or requiring a deep understanding of Javascript build tooling. + +```pyodide +import panel as pn +import param + +from panel.custom import ReactComponent + +pn.extension() + +class CounterButton(ReactComponent): + + value = param.Integer() + + _esm = """ + export function render({model}) { + const [value, setValue] = model.useState("value"); + return ( + + ) + } + """ + +CounterButton().servable() +``` + +:::{note} + +`ReactComponent` extends the [`JSComponent`](JSComponent.md) class, which allows you to create custom Panel components using JavaScript. + +`ReactComponent` bears similarities to [`AnyWidget`](https://anywidget.dev/) and [`IpyReact`](https://github.com/widgetti/ipyreact), but `ReactComponent` is specifically optimized for use with Panel and React. + +If you are looking to create custom components using Python and Panel component only, check out [`Viewer`](Viewer.md). + +::: + +## API + +### ReactComponent Attributes + +- **`_esm`** (str | PurePath): This attribute accepts either a string or a path that points to an [ECMAScript module](https://nodejs.org/api/esm.html#modules-ecmascript-modules). The ECMAScript module should export a `render` function which returns the HTML element to display. In a development environment such as a notebook or when using `--autoreload`, the module will automatically reload upon saving changes. You can use [`JSX`](https://react.dev/learn/writing-markup-with-jsx) and [`TypeScript`](https://www.typescriptlang.org/). The `_esm` script is transpiled on the fly using [Sucrase](https://sucrase.io/). The global namespace contains a `React` object that provides access to React hooks. +- **`_importmap`** (dict | None): This optional dictionary defines an [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap), allowing you to customize how module specifiers are resolved. +- **`_stylesheets`** (List[str | PurePath] | None): This optional attribute accepts a list of CSS strings or paths to CSS files. It supports automatic reloading in development environments. + +:::note + +You may specify a path to a file as a string instead of a PurePath. The path should be specified relative to the file its specified in. + +::: + +#### `render` Function + +The `_esm` attribute must export the `render` function. It accepts the following parameters: + +- **`model`**: Represents the Parameters of the component and provides methods to add (and remove) event listeners using `.on` and `.off`, render child React components using `.get_child`, get a state hook for a parameter value using `.useState` and to `.send_event` back to Python. +- **`view`**: The Bokeh view. +- **`el`**: The HTML element that the component will be rendered into. + +Any React component returned from the `render` function will be appended to the HTML element (`el`) of the component. + +### State Hooks + +The recommended approach to build components that depend on parameters in Python is to create [`useState` hooks](https://react.dev/reference/react/useState) by calling `model.useState('')`. The `model.useState` method returns an array with exactly two values: + +1. The current state. During the first render, it will match the initialState you have passed. +2. The set function that lets you update the state to a different value and trigger a re-render. + +Using the state value in your React component will automatically re-render the component when it is updated. + +### Callbacks + +The `model.on` and `model.off` methods allow registering event handlers inside the render function. This includes the ability to listen to parameter changes and register lifecycle hooks. + +#### Change Events + +The following signatures are valid when listening to change events: + +- `.on('', callback)`: Allows registering an event handler for a single parameter. +- `.on(['', ...], callback)`: Allows adding an event handler for multiple parameters at once. +- `.on('change:', callback)`: The `change:` prefix allows disambiguating change events from lifecycle hooks should a parameter name and lifecycle hook overlap. + +#### Lifecycle Hooks + +- `after_render`: Called once after the component has been fully rendered. +- `after_resize`: Called after the component has been resized. +- `remove`: Called when the component view is being removed from the DOM. + +## Usage + +### Styling with CSS + +Include CSS within the `_stylesheets` attribute to style the component. The CSS is injected directly into the component's HTML. + +```pyodide +import panel as pn +import param + +from panel.custom import ReactComponent + +pn.extension() + +class CounterButton(ReactComponent): + + value = param.Integer() + + _stylesheets = [ + """ + button { + background: #0072B5; + color: white; + border: none; + padding: 10px; + border-radius: 4px; + } + button:hover { + background: #4099da; + } + """ + ] + + _esm = """ + export function render({ model }) { + const [value, setValue] = model.useState("value"); + return ( + + ); + } + """ + +CounterButton().servable() +``` + +## Send Events from JavaScript to Python + +Events from JavaScript can be sent to Python using the `model.send_event` method. Define a handler in Python to manage these events. A *handler* is a method on the form `_handle_(self, event)`: + +```pyodide +import panel as pn +import param + +from panel.custom import ReactComponent + +pn.extension() + +class EventExample(ReactComponent): + + value = param.Parameter() + + _esm = """ + export function render({ model }) { + return ( + + ); + } + """ + + def _handle_click(self, event): + self.value = str(event.__dict__) + +button = EventExample() +pn.Column( + button, pn.widgets.TextAreaInput(value=button.param.value, height=200), +).servable() +``` + +You can also define and send your own custom events: + +```pyodide +import datetime + +import panel as pn +import param + +from panel.custom import ReactComponent + +pn.extension() + +class CustomEventExample(ReactComponent): + + value = param.String() + + _esm = """ + function send_event(model) { + const currentDate = new Date(); + const custom_event = new CustomEvent("click", { detail: currentDate.getTime() }); + model.send_event('click', custom_event) + } + + export function render({ model }) { + return ( + + ); + } + """ + + def _handle_click(self, event): + unix_timestamp = event.data["detail"]/1000 + python_datetime = datetime.datetime.fromtimestamp(unix_timestamp) + self.value = str(python_datetime) + +button = CustomEventExample() +pn.Column( + button, button.param.value, +).servable() +``` + +## Dependency Imports + +JavaScript dependencies can be directly imported via URLs, such as those from [`esm.sh`](https://esm.sh/). + +```pyodide +import panel as pn + +from panel.custom import ReactComponent + +pn.extension() + +class ConfettiButton(ReactComponent): + + _esm = """ + import confetti from "https://esm.sh/canvas-confetti@1.6.0"; + + export function render() { + return ( + + ); + } + """ + +ConfettiButton().servable() +``` + +Use the `_importmap` attribute for more concise module references. + +```pyodide +import panel as pn + +from panel.custom import ReactComponent + +pn.extension() + +class ConfettiButton(ReactComponent): + _importmap = { + "imports": { + "canvas-confetti": "https://esm.sh/canvas-confetti@1.6.0", + } + } + + _esm = """ + import confetti from "canvas-confetti"; + + export function render() { + return ( + + ); + } + """ + +ConfettiButton().servable() +``` + +See [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) for more info about the import map format. + +## External Files + +You can load JSX and CSS from files by providing the paths to these files. + +Create the file **counter_button.py**. + +```python +from pathlib import Path + +import param +import panel as pn + +from panel.custom import ReactComponent + +pn.extension() + +class CounterButton(ReactComponent): + + value = param.Integer() + + _esm = "counter_button.jsx" + _stylesheets = [Path("counter_button.css")] + +CounterButton().servable() +``` + +Now create the file **counter_button.jsx**. + +```javascript +export function render({ model }) { + const [value, setValue] = model.useState("value"); + return ( + + ); +} +``` + +Now create the file **counter_button.css**. + +```css +button { + background: #0072B5; + color: white; + border: none; + padding: 10px; + border-radius: 4px; +} +button:hover { + background: #4099da; +} +``` + +Serve the app with `panel serve counter_button.py --autoreload`. + +You can now edit the JSX or CSS file, and the changes will be automatically reloaded. + +- Try changing `count is {value}` to `COUNT IS {value}` and observe the update. +- Try changing the background color from `#0072B5` to `#008080`. + +## Displaying A Single Child + +You can display Panel components (`Viewable`s) by defining a `Child` parameter. + +Lets start with the simplest example + +```pyodide +import panel as pn + +from panel.custom import Child, ReactComponent + +class Example(ReactComponent): + + child = Child() + + _esm = """ + export function render({ model }) { + return + } + """ + +Example(child=pn.panel("A **Markdown** pane!")).servable() +``` + +If you provide a non-`Viewable` child it will automatically be converted to a `Viewable` by `pn.panel`: + +```pyodide +Example(child="A **Markdown** pane!").servable() +``` + +If you want to allow a certain type of Panel components only you can specify the specific type in the `class_` argument. + +```pyodide +import panel as pn + +from panel.custom import Child, ReactComponent + +class Example(ReactComponent): + + child = Child(class_=pn.pane.Markdown) + + _esm = """ + export function render({ model }) { + return + } + """ + +Example(child=pn.panel("A **Markdown** pane!")).servable() +``` + +The `class_` argument also supports a tuple of types: + +```pyodide +import panel as pn + +from panel.custom import Child, ReactComponent + +class Example(ReactComponent): + + child = Child(class_=(pn.pane.Markdown, pn.pane.HTML)) + + _esm = """ + export function render({ model }) { + return + } + """ + +Example(child=pn.panel("A **Markdown** pane!")).servable() +``` + +## Displaying a List of Children + +You can also display a `List` of `Viewable` objects using the `Children` parameter type: + +```pyodide +import panel as pn + +from panel.custom import Children, ReactComponent + +class Example(ReactComponent): + + objects = Children() + + _esm = """ + export function render({ model }) { + return
{model.get_child("objects")}
+ }""" + + +Example( + objects=[pn.panel("A **Markdown** pane!"), pn.widgets.Button(name="Click me!"), {"text": "I'm shown as a JSON Pane"}] +).servable() +``` + +:::note + +You can change the `item_type` to a specific subtype of `Viewable` or a tuple of +`Viewable` subtypes. + +::: + +## Using React Hooks + +The global namespace also contains a `React` object that provides access to React hooks. Here is an example of a simple counter button using the `useState` hook: + +```pyodide +import panel as pn + +from panel.custom import ReactComponent + +pn.extension() + +class CounterButton(ReactComponent): + + _esm = """ + let { useState } = React; + + export function render() { + const [value, setValue] = useState(0); + return ( + + ); + } + """ + +CounterButton().servable() +``` + +## References + +### Tutorials + +- [Build Custom Components](../../../how_to/custom_components/reactive_esm/reactive_esm_layout.md) + +### How-To Guides + +- [Convert `AnyWidget` widgets](../../../how_to/migrate/anywidget/index.md) + +### Reference Guides + +- [`AnyWidgetComponent`](../../../reference/panes/AnyWidgetComponent.md) +- [`JSComponent`](../../../reference/panes/JSComponent.md) +- [`ReactComponent`](../../../reference/panes/ReactComponent.md) diff --git a/examples/reference/custom_components/Viewer.md b/examples/reference/custom_components/Viewer.md new file mode 100644 index 0000000000..8a2448e0a3 --- /dev/null +++ b/examples/reference/custom_components/Viewer.md @@ -0,0 +1,258 @@ +# `Viewer` + +`Viewer` simplifies the creation of custom Panel components using Python and Panel components only. + +```pyodide +import panel as pn +import param + +from panel.viewable import Viewer + +pn.extension() + +class CounterButton(Viewer): + + value = param.Integer() + + def __init__(self, **params): + super().__init__() + + self._layout = pn.widgets.Button( + name=self._button_name, on_click=self._on_click, **params + ) + + def _on_click(self, event): + self.value += 1 + + @param.depends("value") + def _button_name(self): + return f"Clicked {self.value} times" + + def __panel__(self): + return self._layout + +CounterButton().servable() +``` + +:::{note} + +If you are looking to create new components using JavaScript, check out [`JSComponent`](JSComponent.md), [`ReactComponent`](ReactComponent.md), or [`AnyWidgetComponent`](AnyWidgetComponent.md) instead. + +::: + +## API + +### Attributes + +None. The `Viewer` class does not have any special attributes. It is a simple `param.Parameterized` class with a few additional methods. This also means you will have to add or support parameters like `height`, `width`, `sizing_mode`, etc., yourself if needed. + +### Methods + +- **`__panel__`**: Must be implemented. Should return the Panel component or object to be displayed. +- **`servable`**: This method serves the component using Panel's built-in server when running `panel serve ...`. +- **`show`**: Displays the component in a new browser tab when running `python ...`. + +## Usage + +### Styling with CSS + +You can style the component by styling the component(s) returned by `__panel__` using their `styles` or `stylesheets` attributes. + +```pyodide +import panel as pn +import param + +from panel.viewable import Viewer + +pn.extension() + + +class StyledCounterButton(Viewer): + + value = param.Integer() + + _stylesheets = [ + """ + :host(.solid) .bk-btn.bk-btn-default + { + background: #0072B5; + color: white; + border: none; + padding: 10px; + border-radius: 4px; + } + :host(.solid) .bk-btn.bk-btn-default:hover { + background: #4099da; + } + """ + ] + + def __init__(self, **params): + super().__init__() + + self._layout = pn.widgets.Button( + name=self._button_name, + on_click=self._on_click, + stylesheets=self._stylesheets, + **params, + ) + + def _on_click(self, event): + self.value += 1 + + @param.depends("value") + def _button_name(self): + return f"Clicked {self.value} times" + + def __panel__(self): + return self._layout + + +StyledCounterButton().servable() +``` + +See the [Apply CSS](../../how_to/styling/apply_css.md) guide for more information on styling Panel components. + +## Displaying A Single Child + +You can display Panel components (`Viewable`s) by defining a `Child` parameter. + +Let's start with the simplest example: + +```pyodide +import panel as pn + +from panel.custom import Child +from panel.viewable import Viewer + +class SingleChild(Viewer): + + object = Child() + + def __panel__(self): + return pn.Column("A Single Child", self._object) + + @pn.depends("object") + def _object(self): + return self.object + +single_child = SingleChild(object=pn.pane.Markdown("A **Markdown** pane!")) +single_child.servable() +``` + +The `_object` is a workaround to enable the `_layout` to replace the `object` component dynamically. + +Let's replace the `object` with a `Button`: + +```pyodide +single_child.object = pn.widgets.Button(name="Click me") +``` + +Let's change it back + +```pyodide +single_child.object = pn.pane.Markdown("A **Markdown** pane!") +``` + +If you provide a non-`Viewable` child it will automatically be converted to a `Viewable` by `pn.panel`: + +```pyodide +SingleChild(object="A **Markdown** pane!").servable() +``` + +If you want to allow a certain type of Panel components only, you can specify the specific type in the `class_` argument. + +```pyodide +import panel as pn + +from panel.custom import Child +from panel.viewable import Viewer + +class SingleChild(Viewer): + + object = Child(class_=pn.pane.Markdown) + + def __panel__(self): + return pn.Column("A Single Child", self._object) + + @pn.depends("object") + def _object(self): + return self.object + +SingleChild(object=pn.pane.Markdown("A **Markdown** pane!")).servable() +``` + +The `class_` argument also supports a tuple of types: + +```pyodide +import panel as pn + +from panel.custom import Child +from panel.viewable import Viewer + +class SingleChild(Viewer): + + object = Child(class_=(pn.pane.Markdown, pn.widgets.Button)) + + def __panel__(self): + return pn.Column("A Single Child", self._object) + + @pn.depends("object") + def _object(self): + return self.object + +SingleChild(object=pn.pane.Markdown("A **Markdown** pane!")).servable() +``` + +## Displaying a List of Children + +You can also display a `List` of `Viewable` objects using the `Children` parameter type: + +```pyodide +import panel as pn + +from panel.custom import Children +from panel.viewable import Viewer + + +class MultipleChildren(Viewer): + + objects = Children() + + def __init__(self, **params): + self._layout = pn.Column(styles={"background": "silver"}) + + super().__init__(**params) + + def __panel__(self): + return self._layout + + @pn.depends("objects", watch=True, on_init=True) + def _objects(self): + self._layout[:] = self.objects + + +MultipleChildren( + objects=[ + pn.panel("A **Markdown** pane!"), + pn.widgets.Button(name="Click me!"), + {"text": "I'm shown as a JSON Pane"}, + ] +).servable() +``` + +:::note + +You can change the `item_type` to a specific subtype of `Viewable` or a tuple of `Viewable` subtypes. + +::: + +## References + +### Tutorials + +- [Reusable Components](../../../tutorials/intermediate/reusable_components.md) + +### How-To Guides + +- [Combine Existing Widgets](../../../how_to/custom_components/custom_viewer.md) diff --git a/panel/.eslintrc.js b/panel/.eslintrc.js index 3050c3d79d..66a06d4024 100644 --- a/panel/.eslintrc.js +++ b/panel/.eslintrc.js @@ -9,7 +9,7 @@ module.exports = { }, "plugins": ["@typescript-eslint", "@stylistic/eslint-plugin"], "extends": [], - "ignorePatterns": ["*/dist", "*/theme/**/*.js", ".eslintrc.js", "*/_templates/*.js", "*/template/**/*.js"], + "ignorePatterns": ["*/dist", "*/theme/**/*.js", ".eslintrc.js", "*/_templates/*.js", "*/template/**/*.js", "examples/*"], "rules": { "@typescript-eslint/ban-types": ["error", { "types": { diff --git a/panel/_templates/js_resources.html b/panel/_templates/js_resources.html index 3bc1f06b9e..b0912f0f7e 100644 --- a/panel/_templates/js_resources.html +++ b/panel/_templates/js_resources.html @@ -15,6 +15,8 @@ :type js_raw: list[str] #} + + {% for file in js_files %} {% endfor %} diff --git a/panel/chat/feed.py b/panel/chat/feed.py index 0688bd432c..56c1b4ebca 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -27,6 +27,7 @@ from ..layout.spacer import VSpacer from ..pane.image import SVG from ..util import to_async_gen +from ..viewable import Children from .icon import ChatReactionIcons from .message import ChatMessage @@ -126,7 +127,7 @@ class ChatFeed(ListPanel): be specified as a two-tuple of the form (vertical, horizontal) or a four-tuple (top, right, bottom, left).""") - objects = param.List(default=[], doc=""" + objects = Children(default=[], doc=""" The list of child objects that make up the layout.""") help_text = param.String(default="", doc=""" diff --git a/panel/chat/icon.py b/panel/chat/icon.py index c984f56a72..d7c2729323 100644 --- a/panel/chat/icon.py +++ b/panel/chat/icon.py @@ -45,7 +45,7 @@ class ChatReactionIcons(CompositeWidget): A key-value pair of reaction values and their corresponding tabler icon names found on https://tabler-icons.io.""") - value = param.List(doc="The active reactions.") + value = param.List(default=[], doc="The active reactions.") _stylesheets: ClassVar[list[str]] = [f"{CDN_DIST}css/chat_reaction_icons.css"] diff --git a/panel/chat/interface.py b/panel/chat/interface.py index 4746f94a41..2ef168ebcb 100644 --- a/panel/chat/interface.py +++ b/panel/chat/interface.py @@ -17,7 +17,7 @@ from ..layout import Row, Tabs from ..pane.image import ImageBase from ..viewable import Viewable -from ..widgets.base import Widget +from ..widgets.base import WidgetBase from ..widgets.button import Button from ..widgets.input import FileInput, TextInput from .feed import CallbackState, ChatFeed @@ -110,7 +110,7 @@ class ChatInterface(ChatFeed): user = param.String(default="User", doc=""" Name of the ChatInterface user.""") - widgets = param.ClassSelector(class_=(Widget, list), allow_refs=False, doc=""" + widgets = param.ClassSelector(class_=(WidgetBase, list), allow_refs=False, doc=""" Widgets to use for the input. If not provided, defaults to `[TextInput]`.""") @@ -251,7 +251,7 @@ def _init_widgets(self): ) widgets = self.widgets - if isinstance(self.widgets, Widget): + if isinstance(self.widgets, WidgetBase): widgets = [self.widgets] self._widgets = {} @@ -539,7 +539,7 @@ def _click_clear( self._reset_button_data() @property - def active_widget(self) -> Widget: + def active_widget(self) -> WidgetBase: """ The currently active widget. diff --git a/panel/chat/message.py b/panel/chat/message.py index 7570e1bf2e..99a6e57fd9 100644 --- a/panel/chat/message.py +++ b/panel/chat/message.py @@ -23,7 +23,7 @@ from ..io.resources import CDN_DIST, get_dist_path from ..io.state import state from ..layout import Column, Row -from ..pane.base import PaneBase, ReplacementPane, panel as _panel +from ..pane.base import Pane, ReplacementPane, panel as _panel from ..pane.image import ( PDF, FileBase, Image, ImageBase, ) @@ -132,7 +132,7 @@ class _FileInputMessage: mime_type: str -class ChatMessage(PaneBase): +class ChatMessage(Pane): """ A widget for displaying chat messages with support for various content types. diff --git a/panel/custom.py b/panel/custom.py new file mode 100644 index 0000000000..78c30f5913 --- /dev/null +++ b/panel/custom.py @@ -0,0 +1,420 @@ +from __future__ import annotations + +import asyncio +import inspect +import os +import pathlib +import textwrap + +from collections import defaultdict +from functools import partial +from typing import ( + TYPE_CHECKING, Any, Callable, ClassVar, Literal, Mapping, Optional, +) + +import param + +from param.parameterized import ParameterizedMetaclass + +from .config import config +from .io.datamodel import construct_data_model +from .io.notebook import push +from .io.state import state +from .models import ( + AnyWidgetComponent as _BkAnyWidgetComponent, + ReactComponent as _BkReactComponent, ReactiveESM as _BkReactiveESM, +) +from .models.esm import ESMEvent +from .models.reactive_html import DOMEvent +from .pane.base import PaneBase # noqa +from .reactive import ( # noqa + Reactive, ReactiveCustomBase, ReactiveHTML, ReactiveMetaBase, +) +from .util.checks import import_available +from .viewable import ( # noqa + Child, Children, Layoutable, Viewable, is_viewable_param, +) +from .widgets.base import WidgetBase # noqa + +if TYPE_CHECKING: + from bokeh.document import Document + from bokeh.events import Event + from bokeh.model import Model + from pyviz_comms import Comm + + +class ReactiveESMMetaclass(ReactiveMetaBase): + + def __init__(mcs, name: str, bases: tuple[type, ...], dict_: Mapping[str, Any]): + mcs.__original_doc__ = mcs.__doc__ + ParameterizedMetaclass.__init__(mcs, name, bases, dict_) + + # Create model with unique name + ReactiveMetaBase._name_counter[name] += 1 + model_name = f'{name}{ReactiveMetaBase._name_counter[name]}' + ignored = [p for p in Reactive.param if not issubclass(type(mcs.param[p].owner), ReactiveESMMetaclass)] + mcs._data_model = construct_data_model( + mcs, name=model_name, ignore=ignored + ) + + +class ReactiveESM(ReactiveCustomBase, metaclass=ReactiveESMMetaclass): + ''' + The `ReactiveESM` classes allow you to create custom Panel + components using HTML, CSS and/ or Javascript and without the + complexities of Javascript build tools. + + A `ReactiveESM` subclass provides bi-directional syncing of its + parameters with arbitrary HTML elements, attributes and + properties. The key part of the subclass is the `_esm` + variable. Use this to define a `render` function as shown in the + example below. + + import panel as pn + import param + + pn.extension() + + class CounterButton(pn.ReactiveESM): + + value = param.Integer() + + _esm = """ + export function render({ data }) { + let btn = document.createElement("button"); + btn.innerHTML = `count is ${data.value}`; + btn.addEventListener("click", () => { + data.value += 1 + }); + data.watch(() => { + btn.innerHTML = `count is ${data.value}`; + }, 'value') + return btn + } + """ + + CounterButton().servable() + ''' + + _bokeh_model = _BkReactiveESM + + _esm: ClassVar[str | os.PathLike] = "" + + _importmap: ClassVar[dict[Literal['imports', 'scopes'], str]] = {} + + __abstract = True + + def __init__(self, **params): + super().__init__(**params) + self._watching_esm = False + self._event_callbacks = defaultdict(list) + + @property + def _esm_path(self): + esm = self._esm + if isinstance(esm, pathlib.PurePath): + return esm + try: + esm_path = pathlib.Path(inspect.getfile(type(self))).parent / esm + if esm_path.is_file(): + return esm_path + except (OSError, TypeError, ValueError): + pass + return None + + def _render_esm(self): + if (esm_path:= self._esm_path): + esm = esm_path.read_text(encoding='utf-8') + else: + esm = self._esm + esm = textwrap.dedent(esm) + return esm + + def _cleanup(self, root: Model | None) -> None: + if root: + ref = root.ref['id'] + if ref in self._models: + model, _ = self._models[ref] + for child in model.children: + children = getattr(self, child) + if isinstance(children, Viewable): + children = [children] + if isinstance(children, list): + for child in children: + if isinstance(child, Viewable): + child._cleanup(root) + super()._cleanup(root) + if not self._models and self._watching_esm: + self._watching_esm.set() + self._watching_esm = False + + async def _watch_esm(self): + import watchfiles + async for _ in watchfiles.awatch(self._esm_path, stop_event=self._watching_esm): + self._update_esm() + + def _update_esm(self): + esm = self._render_esm() + for ref, (model, _) in self._models.items(): + if esm == model.esm: + continue + self._apply_update({}, {'esm': esm}, model, ref) + + @property + def _linked_properties(self) -> list[str]: + return [p for p in self._data_model.properties() if p not in ('js_property_callbacks',)] + + def _init_params(self) -> dict[str, Any]: + cls = type(self) + ignored = [p for p in Reactive.param if not issubclass(cls.param[p].owner, ReactiveESM)] + params = { + p : getattr(self, p) for p in list(Layoutable.param) + if getattr(self, p) is not None and p != 'name' + } + data_params = {} + for k, v in self.param.values().items(): + if ( + (k in ignored and k != 'name') or + (((p:= self.param[k]).precedence or 0) < 0) or + is_viewable_param(p) + ): + continue + if k in params: + params.pop(k) + data_params[k] = v + data_props = self._process_param_change(data_params) + params.update({ + 'data': self._data_model(**{p: v for p, v in data_props.items() if p not in ignored}), + 'dev': config.autoreload or getattr(self, '_debug', False), + 'esm': self._render_esm(), + 'importmap': self._process_importmap(), + }) + return params + + def _process_importmap(self): + return self._importmap + + def _get_children(self, data_model, doc, root, parent, comm): + children = {} + ref = root.ref['id'] + for k, v in self.param.values().items(): + p = self.param[k] + if not is_viewable_param(p): + continue + if v is None: + children[k] = None + elif isinstance(v, list): + children[k] = [ + sv._models[ref][0] if ref in sv._models else sv._get_model(doc, root, parent, comm) + for sv in v + ] + elif ref in v._models: + children[k] = v._models[ref][0] + else: + children[k] = v._get_model(doc, root, parent, comm) + return children + + def _setup_autoreload(self): + from .config import config + if not ((config.autoreload or getattr(self, '_debug', False)) and import_available('watchfiles')): + return + super()._setup_autoreload() + if (self._esm_path and not self._watching_esm): + self._watching_esm = asyncio.Event() + state.execute(self._watch_esm) + + def _get_model( + self, doc: Document, root: Optional[Model] = None, + parent: Optional[Model] = None, comm: Optional[Comm] = None + ) -> Model: + model = self._bokeh_model(**self._get_properties(doc)) + root = root or model + children = self._get_children(model.data, doc, root, model, comm) + model.data.update(**children) + model.children = list(children) + self._models[root.ref['id']] = (model, parent) + self._link_props(model.data, self._linked_properties, doc, root, comm) + self._register_events('dom_event', model=model, doc=doc, comm=comm) + self._setup_autoreload() + return model + + def _process_event(self, event: 'Event') -> None: + if not isinstance(event, DOMEvent): + return + if hasattr(self, f'_handle_{event.node}'): + getattr(self, f'_handle_{event.node}')(event) + for cb in self._event_callbacks.get(event.node, []): + cb(event) + + def _update_model( + self, events: dict[str, param.parameterized.Event], msg: dict[str, Any], + root: Model, model: Model, doc: Document, comm: Optional[Comm] + ) -> None: + model_msg, data_msg = {}, {} + for prop, v in list(msg.items()): + if prop in list(Reactive.param)+['esm', 'importmap']: + model_msg[prop] = v + elif prop in model.children: + continue + else: + data_msg[prop] = v + for name, event in events.items(): + if name not in model.children: + continue + new = event.new + old_objects = event.old if isinstance(event.old, list) else [event.old] + for old in old_objects: + if old is None or old is new or (isinstance(new, list) and old in new): + continue + old._cleanup(root) + if any(e in model.children for e in events): + children = self._get_children(model.data, doc, root, model, comm) + data_msg.update(children) + model_msg['children'] = list(children) + self._set_on_model(model_msg, root, model) + self._set_on_model(data_msg, root, model.data) + + def on_event(self, event: str, callback: Callable) -> None: + """ + Registers a callback to be executed when the specified DOM + event is triggered. + + Arguments + --------- + event: str + Name of the DOM event to add an event listener to. + callback: callable + A callable which will be given the DOMEvent object. + """ + self._event_callbacks[event].append(callback) + + +class JSComponent(ReactiveESM): + ''' + The `JSComponent` allows you to create custom Panel components + using Javascript and CSS without the complexities of + Javascript build tools. + + A `JSComponent` subclass provides bi-directional syncing of its + parameters with arbitrary HTML elements, attributes and + properties. The key part of the subclass is the `_esm` + variable. Use this to define a `render` function as shown in the + example below. + + import panel as pn + import param + + pn.extension() + + class CounterButton(JSComponent): + + value = param.Integer() + + _esm = """ + export function render({ data }) { + let btn = document.createElement("button"); + btn.innerHTML = `count is ${data.value}`; + btn.addEventListener("click", () => { + data.value += 1 + }); + data.watch(() => { + btn.innerHTML = `count is ${data.value}`; + }, 'value') + return btn + } + """ + + CounterButton().servable() + ''' + + +class ReactComponent(ReactiveESM): + ''' + The `ReactComponent` allows you to create custom Panel components + using React without the complexities of Javascript build tools. + + A `ReactComponent` subclass provides bi-directional syncing of its + parameters with arbitrary HTML elements, attributes and + properties. The key part of the subclass is the `_esm` + variable. Use this to define a `render` function as shown in the + example below. + + import panel as pn + import param + + pn.extension() + + class CounterButton(ReactComponent): + + value = param.Integer() + + _esm = """ + export function render({ data, state }) { + return ( + <> + + + ) + } + """ + + CounterButton().servable() + ''' + + + _bokeh_model = _BkReactComponent + + _react_version = '18.3.1' + + def _init_params(self) -> dict[str, Any]: + params = super()._init_params() + params['react_version'] = self._react_version + return params + + def _process_importmap(self): + imports = self._importmap.get('imports', {}) + imports_with_deps = {} + dev_suffix = '&dev' if config.autoreload else '' + suffix = f'deps=react@{self._react_version},react-dom@{self._react_version}&external=react{dev_suffix}' + for k, v in imports.items(): + if '?' not in v and 'esm.sh' in v: + if v.endswith('/'): + v = f'{v[:-1]}&{suffix}/' + else: + v = f'{v}?{suffix}' + imports_with_deps[k] = v + return { + 'imports': imports_with_deps, + 'scopes': self._importmap.get('scopes', {}) + } + +class AnyWidgetComponent(ReactComponent): + """ + The `AnyWidgetComponent` allows you to create custom Panel components + in the style of an AnyWidget component. Specifically this component + type creates shims that make it possible to reuse AnyWidget ESM code + as is, without having to adapt the callbacks to use Bokeh APIs. + """ + + _bokeh_model = _BkAnyWidgetComponent + + def send(self, msg: dict): + """ + Sends a custom event containing the provided message to the frontend. + + Arguments + --------- + msg: dict + """ + for ref, (model, _) in self._models.items(): + if ref not in state._views or ref in state._fake_roots: + continue + event = ESMEvent(model=model, data=msg) + viewable, root, doc, comm = state._views[ref] + if comm or state._unblocked(doc) or not doc.session_context: + doc.callbacks.send_event(event) + if comm and 'embedded' not in root.tags: + push(doc, comm) + else: + cb = partial(doc.callbacks.send_event, event) + doc.add_next_tick_callback(cb) diff --git a/panel/interact.py b/panel/interact.py index e069cc0498..810824d387 100644 --- a/panel/interact.py +++ b/panel/interact.py @@ -21,10 +21,10 @@ import param from .layout import Column, Panel, Row -from .pane import HTML, PaneBase, panel +from .pane import HTML, Pane, panel from .pane.base import ReplacementPane from .viewable import Viewable -from .widgets import Button, Widget +from .widgets import Button, WidgetBase from .widgets.widget import fixed, widget if TYPE_CHECKING: @@ -61,7 +61,7 @@ def _yield_abbreviations_for_parameter(parameter, kwargs): yield k, v, empty -class interactive(PaneBase): +class interactive(Pane): default_layout = param.ClassSelector(default=Column, class_=(Panel), is_instance=False) @@ -107,7 +107,7 @@ def __init__(self, object, params={}, **kwargs): self._pane = panel(pane, name=self.name) self._internal = True self._inner_layout = Row(self._pane) - widgets = [widget for _, widget in widgets if isinstance(widget, Widget)] + widgets = [widget for _, widget in widgets if isinstance(widget, WidgetBase)] if 'name' in params: widgets.insert(0, HTML(f'

{self.name}

')) self.widget_box = Column(*widgets) @@ -213,7 +213,7 @@ def widgets_from_abbreviations(self, seq): widget_obj = abbrev else: widget_obj = widget(abbrev, name=name, default=default) - if not (isinstance(widget_obj, Widget) or isinstance(widget_obj, fixed)): + if not (isinstance(widget_obj, WidgetBase) or isinstance(widget_obj, fixed)): if widget_obj is None: continue else: diff --git a/panel/io/datamodel.py b/panel/io/datamodel.py index d0f9ac30ff..7a486553e0 100644 --- a/panel/io/datamodel.py +++ b/panel/io/datamodel.py @@ -6,10 +6,11 @@ import bokeh.core.properties as bp import param as pm -from bokeh.model import DataModel +from bokeh.model import DataModel, Model from bokeh.models import ColumnDataSource from ..reactive import Syncable +from ..viewable import Child, Children, Viewable from .document import unlocked from .notebook import push from .state import state @@ -65,21 +66,34 @@ def color_param_to_ppt(p, kwargs): def list_param_to_ppt(p, kwargs): - if isinstance(p.item_type, type) and issubclass(p.item_type, pm.Parameterized): + item_type = bp.Any + if not isinstance(p.item_type, type): + pass + elif issubclass(p.item_type, Viewable): + item_type = bp.Instance(Model) + elif issubclass(p.item_type, pm.Parameterized): return bp.List(bp.Instance(DataModel)), [(ParameterizedList, lambda ps: [create_linked_datamodel(p) for p in ps])] - return bp.List(bp.Any, **kwargs) + return bp.List(item_type, **kwargs) +def class_selector_to_model(p, kwargs): + if isinstance(p.class_, type) and issubclass(p.class_, Viewable): + return bp.Nullable(bp.Instance(Model), **kwargs) + elif isinstance(p.class_, type) and issubclass(p.class_, pm.Parameterized): + return (bp.Instance(DataModel, **kwargs), [(Parameterized, create_linked_datamodel)]) + else: + return bp.Any(**kwargs) + +def bytes_param(p, kwargs): + kwargs.pop('default') + return bp.Bytes(**kwargs) PARAM_MAPPING = { pm.Array: lambda p, kwargs: bp.Array(bp.Any, **kwargs), pm.Boolean: lambda p, kwargs: bp.Bool(**kwargs), + pm.Bytes: lambda p, kwargs: bytes_param(p, kwargs), pm.CalendarDate: lambda p, kwargs: bp.Date(**kwargs), pm.CalendarDateRange: lambda p, kwargs: bp.Tuple(bp.Date, bp.Date, **kwargs), - pm.ClassSelector: lambda p, kwargs: ( - (bp.Instance(DataModel, **kwargs), [(Parameterized, create_linked_datamodel)]) - if isinstance(p.class_, type) and issubclass(p.class_, pm.Parameterized) else - bp.Any(**kwargs) - ), + pm.ClassSelector: class_selector_to_model, pm.Color: color_param_to_ppt, pm.DataFrame: lambda p, kwargs: ( bp.ColumnData(bp.Any, bp.Seq(bp.Any), **kwargs), @@ -96,10 +110,11 @@ def list_param_to_ppt(p, kwargs): pm.Range: lambda p, kwargs: bp.Tuple(bp.Float, bp.Float, **kwargs), pm.String: lambda p, kwargs: bp.String(**kwargs), pm.Tuple: lambda p, kwargs: bp.Tuple(*(bp.Any for p in range(p.length)), **kwargs), + Child: lambda p, kwargs: bp.Nullable(bp.Instance(Model), **kwargs), + Children: lambda p, kwargs: bp.List(bp.Instance(Model), **kwargs), } - def construct_data_model(parameterized, name=None, ignore=[], types={}): """ Dynamically creates a Bokeh DataModel class from a Parameterized diff --git a/panel/io/resources.py b/panel/io/resources.py index 841f721d4f..2943cbf436 100644 --- a/panel/io/resources.py +++ b/panel/io/resources.py @@ -13,12 +13,14 @@ import pathlib import re import textwrap +import uuid from contextlib import contextmanager from functools import lru_cache from pathlib import Path from typing import TYPE_CHECKING, Literal, TypedDict +import bokeh.embed.wrappers import param from bokeh.embed.bundle import ( @@ -145,6 +147,16 @@ def parse_template(*args, **kwargs): extension_dirs['panel'] = str(DIST_DIR) +bokeh.embed.wrappers._ONLOAD = """\ +(function() { + const fn = function() { +%(code)s + }; + if (document.readyState != "loading") fn(); +else document.addEventListener("DOMContentLoaded", fn, {once: true}); +})();\ +""" + mimetypes.add_type("application/javascript", ".js") @contextmanager @@ -291,6 +303,8 @@ def resolve_stylesheet(cls, stylesheet: str, attribute: str | None = None): if not stylesheet.startswith('http') and attribute and _is_file_path(stylesheet) and (custom_path:= resolve_custom_path(cls, stylesheet)): if not state._is_pyodide and state.curdoc and state.curdoc.session_context: stylesheet = component_resource_path(cls, attribute, stylesheet) + if config.autoreload and '?' not in stylesheet: + stylesheet += f'?v={uuid.uuid4().hex}' else: stylesheet = custom_path.read_text(encoding='utf-8') return stylesheet @@ -325,10 +339,15 @@ def global_css(name): def bundled_files(model, file_type='javascript'): name = model.__name__.lower() + raw_files = getattr(model, f"__{file_type}_raw__", []) + for cls in model.__mro__[1:]: + cls_files = getattr(cls, f"__{file_type}_raw__", []) + if raw_files is cls_files: + name = cls.__name__.lower() bdir = BUNDLE_DIR / name shared = list((JS_URLS if file_type == 'javascript' else CSS_URLS).values()) files = [] - for url in getattr(model, f"__{file_type}_raw__", []): + for url in raw_files: if url.startswith(CDN_DIST): filepath = url.replace(f'{CDN_DIST}bundled/', '') elif url.startswith(config.npm_cdn): @@ -616,8 +635,8 @@ def extra_resources(self, resources, resource_type): """ Adds resources for ReactiveHTML components. """ - from ..reactive import ReactiveHTML - for model in param.concrete_descendents(ReactiveHTML).values(): + from ..reactive import ReactiveCustomBase + for model in param.concrete_descendents(ReactiveCustomBase).values(): if not (getattr(model, resource_type, None) and model._loaded()): continue for resource in getattr(model, resource_type, []): @@ -768,12 +787,15 @@ def js_files(self): @property def js_modules(self): from ..config import config - from ..reactive import ReactiveHTML + from ..reactive import ReactiveCustomBase modules = list(config.js_modules.values()) for model in Model.model_class_reverse_map.values(): - if hasattr(model, '__javascript_modules__'): - modules.extend(model.__javascript_modules__) + if not hasattr(model, '__javascript_modules__'): + continue + for module in model.__javascript_modules__: + if module not in modules: + modules.append(module) self.extra_resources(modules, '__javascript_modules__') if config.design: @@ -785,7 +807,7 @@ def js_modules(self): if res not in modules ] - for model in param.concrete_descendents(ReactiveHTML).values(): + for model in param.concrete_descendents(ReactiveCustomBase).values(): if not (getattr(model, '__javascript_modules__', None) and model._loaded()): continue for js_module in model.__javascript_modules__: diff --git a/panel/layout/base.py b/panel/layout/base.py index 7063425fd0..15c7f1d93c 100644 --- a/panel/layout/base.py +++ b/panel/layout/base.py @@ -6,7 +6,8 @@ from collections import defaultdict, namedtuple from typing import ( - TYPE_CHECKING, Any, ClassVar, Iterable, Iterator, Mapping, Optional, + TYPE_CHECKING, Any, ClassVar, Generator, Iterable, Iterator, Mapping, + Optional, ) import param @@ -21,6 +22,7 @@ from ..models import Column as PnColumn from ..reactive import Reactive from ..util import param_name, param_reprs, param_watchers +from ..viewable import Children if TYPE_CHECKING: from bokeh.document import Document @@ -146,11 +148,8 @@ def _get_objects( Returns new child models for the layout while reusing unchanged models and cleaning up any dropped objects. """ - from ..pane.base import RerenderError, panel + from ..pane.base import RerenderError new_models, old_models = [], [] - for i, pane in enumerate(self.objects): - pane = panel(pane) - self.objects[i] = pane for obj in old_objects: if obj not in self.objects: @@ -345,11 +344,25 @@ def select(self, selector=None): class ListLike(param.Parameterized): - objects = param.List(default=[], doc=""" + objects = Children(default=[], doc=""" The list of child objects that make up the layout.""") _preprocess_params: ClassVar[list[str]] = ['objects'] + def __init__(self, *objects: Any, **params: Any): + if objects: + if 'objects' in params: + raise ValueError( + f"A {type(self).__name__}'s objects should be supplied either " + "as positional arguments or as a keyword, not both." + ) + params['objects'] = list(objects) + elif 'objects' in params: + objects = params['objects'] + if not (resolve_ref(objects) or iscoroutinefunction(objects) or isinstance(objects, Generator)): + params['objects'] = list(objects) + super().__init__(**params) + def __getitem__(self, index: int | slice) -> Viewable | list[Viewable]: return self.objects[index] @@ -381,7 +394,6 @@ def __contains__(self, obj: Viewable) -> bool: return obj in self.objects def __setitem__(self, index: int | slice, panes: Iterable[Any]) -> None: - from ..pane import panel new_objects = list(self) if not isinstance(index, slice): start, end = index, index+1 @@ -412,7 +424,7 @@ def __setitem__(self, index: int | slice, panes: Iterable[Any]) -> None: 'on the %s to match the supplied slice.' % (expected, type(self).__name__)) for i, pane in zip(range(start, end), panes): - new_objects[i] = panel(pane) + new_objects[i] = pane self.objects = new_objects @@ -449,9 +461,8 @@ def append(self, obj: Any) -> None: --------- obj (object): Panel component to add to the layout. """ - from ..pane import panel new_objects = list(self) - new_objects.append(panel(obj)) + new_objects.append(obj) self.objects = new_objects def clear(self) -> list[Viewable]: @@ -474,9 +485,8 @@ def extend(self, objects: Iterable[Any]) -> None: --------- objects (list): List of panel components to add to the layout. """ - from ..pane import panel new_objects = list(self) - new_objects.extend(list(map(panel, objects))) + new_objects.extend(objects) self.objects = new_objects def index(self, object) -> int: @@ -502,9 +512,8 @@ def insert(self, index: int, obj: Any) -> None: index (int): Index at which to insert the object. object (object): Panel components to insert in the layout. """ - from ..pane import panel new_objects = list(self) - new_objects.insert(index, panel(obj)) + new_objects.insert(index, obj) self.objects = new_objects def pop(self, index: int) -> Viewable: @@ -543,7 +552,7 @@ def reverse(self) -> None: class NamedListLike(param.Parameterized): - objects = param.List(default=[], doc=""" + objects = Children(default=[], doc=""" The list of child objects that make up the layout.""") _preprocess_params: ClassVar[list[str]] = ['objects'] @@ -551,9 +560,10 @@ class NamedListLike(param.Parameterized): def __init__(self, *items: list[Any | tuple[str, Any]], **params: Any): if 'objects' in params: if items: - raise ValueError(f'{type(self).__name__} objects should be supplied either ' - 'as positional arguments or as a keyword, ' - 'not both.') + raise ValueError( + f'{type(self).__name__} objects should be supplied either ' + 'as positional arguments or as a keyword, not both.' + ) items = params.pop('objects') params['objects'], self._names = self._to_objects_and_names(items) super().__init__(**params) @@ -809,20 +819,6 @@ class ListPanel(ListLike, Panel): __abstract = True - def __init__(self, *objects: Any, **params: Any): - from ..pane import panel - if objects: - if 'objects' in params: - raise ValueError(f"A {type(self).__name__}'s objects should be supplied either " - "as positional arguments or as a keyword, " - "not both.") - params['objects'] = [panel(pane) for pane in objects] - elif 'objects' in params: - objects = params['objects'] - if not resolve_ref(objects) or iscoroutinefunction(objects): - params['objects'] = [panel(pane) for pane in objects] - super(Panel, self).__init__(**params) - @property def _linked_properties(self): return tuple( diff --git a/panel/layout/feed.py b/panel/layout/feed.py index 58144e2fdc..b6ee9cf1d9 100644 --- a/panel/layout/feed.py +++ b/panel/layout/feed.py @@ -149,13 +149,10 @@ def _get_objects( self, model: Model, old_objects: list[Viewable], doc: Document, root: Model, comm: Optional[Comm] = None ): - from ..pane.base import RerenderError, panel + from ..pane.base import RerenderError new_models, old_models = [], [] self._last_synced = self._synced_range - for i, pane in enumerate(self.objects): - self.objects[i] = panel(pane) - for obj in old_objects: if obj not in self.objects: obj._cleanup(root) diff --git a/panel/links.py b/panel/links.py index e2e7d8ab84..d2a2853a54 100644 --- a/panel/links.py +++ b/panel/links.py @@ -17,7 +17,7 @@ from .io.datamodel import create_linked_datamodel from .io.loading import LOADING_INDICATOR_CSS_CLASS -from .models import ReactiveHTML +from .models import ReactiveESM, ReactiveHTML from .reactive import Reactive from .util.warnings import warn from .viewable import Viewable @@ -475,11 +475,11 @@ def _init_callback( references[k] = v # Handle links with ReactiveHTML DataModel - if isinstance(src_model, ReactiveHTML): + if isinstance(src_model, (ReactiveESM, ReactiveHTML)): if src_spec[1] in src_model.data.properties(): # type: ignore references['source'] = src_model = src_model.data # type: ignore - if isinstance(tgt_model, ReactiveHTML): + if isinstance(tgt_model, (ReactiveESM, ReactiveHTML)): if tgt_spec[1] in tgt_model.data.properties(): # type: ignore references['target'] = tgt_model = tgt_model.data # type: ignore diff --git a/panel/models/__init__.py b/panel/models/__init__.py index 23bb5c002f..c00045f5bd 100644 --- a/panel/models/__init__.py +++ b/panel/models/__init__.py @@ -4,9 +4,9 @@ defined as pairs of Python classes and TypeScript models defined in .ts files. """ - from .browser import BrowserInfo # noqa from .datetime_picker import DatetimePicker # noqa +from .esm import AnyWidgetComponent, ReactComponent, ReactiveESM # noqa from .feed import Feed # noqa from .icon import ButtonIcon, ToggleIcon, _ClickableIcon # noqa from .ipywidget import IPyWidget # noqa diff --git a/panel/models/anywidget_component.ts b/panel/models/anywidget_component.ts new file mode 100644 index 0000000000..796a2aea8b --- /dev/null +++ b/panel/models/anywidget_component.ts @@ -0,0 +1,155 @@ +import type * as p from "@bokehjs/core/properties" + +import {ReactiveESM} from "./reactive_esm" +import {ReactComponent, ReactComponentView} from "./react_component" + +class AnyWidgetModelAdapter { + declare model: AnyWidgetComponent + declare model_changes: any + declare data_changes: any + declare view: AnyWidgetComponentView | null + + constructor(model: AnyWidgetComponent) { + this.view = null + this.model = model + this.model_changes = {} + this.data_changes = {} + } + + get(name: any) { + let value + if (name in this.model.data.attributes) { + value = this.model.data.attributes[name] + } else { + value = this.model.attributes[name] + } + if (value instanceof ArrayBuffer) { + value = new DataView(value) + } + return value + } + + set(name: string, value: any) { + if (name in this.model.data.attributes) { + this.data_changes = {...this.data_changes, [name]: value} + } else if (name in this.model.attributes) { + this.model_changes = {...this.model_changes, [name]: value} + } + } + + save_changes() { + this.model.setv(this.model_changes) + this.model_changes = {} + this.model.data.setv(this.data_changes) + this.data_changes = {} + } + + on(event: string, cb: () => void) { + if (event.startsWith("change:")) { + this.model.watch(this.view, event.slice("change:".length), cb) + } else if (event === "msg:custom" && this.view) { + this.view.on_event(cb) + } else { + console.error(`Event of type '${event}' not recognized.`) + } + } + + off(event: string, cb: () => void) { + if (event.startsWith("change:")) { + this.model.unwatch(this.view, event.slice("change:".length), cb) + } else if (event === "msg:custom" && this.view) { + this.view.remove_on_event(cb) + } else { + console.error(`Event of type '${event}' not recognized.`) + } + } +} + +class AnyWidgetAdapter extends AnyWidgetModelAdapter { + declare view: AnyWidgetComponentView + + constructor(view: AnyWidgetComponentView) { + super(view.model) + this.view = view + } + + get_child(name: any): HTMLElement | HTMLElement[] | undefined { + const child_model = this.model.data[name] + if (Array.isArray(child_model)) { + const subchildren = [] + for (const subchild of child_model) { + const subview = this.view.get_child_view(subchild) + if (subview) { + subchildren.push(subview.el) + } + } + return subchildren + } else { + return this.view.get_child_view(child_model)?.el + } + } + +} + +export class AnyWidgetComponentView extends ReactComponentView { + declare model: AnyWidgetComponent + adapter: AnyWidgetAdapter + destroyer: ((props: any) => void) | null + + override initialize(): void { + super.initialize() + this.adapter = new AnyWidgetAdapter(this) + } + + override remove(): void { + super.remove() + if (this.destroyer) { + this.destroyer({model: this.adapter, el: this.container}) + } + } + + protected override _render_code(): string { + return ` +const view = Bokeh.index.find_one_by_id('${this.model.id}') + +let props = {view, model: view.adapter, data: view.model.data, el: view.container} + +view.destroyer = view.render_fn(props) || null` + } + + override after_rendered(): void { + this.render_children() + this._rendered = true + } +} + +export namespace AnyWidgetComponent { + export type Attrs = p.AttrsOf + + export type Props = ReactComponent.Props +} + +export interface AnyWidgetComponent extends AnyWidgetComponent.Attrs {} + +export class AnyWidgetComponent extends ReactComponent { + declare properties: AnyWidgetComponent.Props + + constructor(attrs?: Partial) { + super(attrs) + } + + protected override _run_initializer(initialize: (props: any) => void): void { + const props = {model: new AnyWidgetModelAdapter(this)} + initialize(props) + } + + override compile(): string | null { + return ReactiveESM.prototype.compile.call(this) + } + + static override __module__ = "panel.models.esm" + + static { + this.prototype.default_view = AnyWidgetComponentView + } +} diff --git a/panel/models/deckgl.ts b/panel/models/deckgl.ts index 6776ddce00..b5bba42acf 100644 --- a/panel/models/deckgl.ts +++ b/panel/models/deckgl.ts @@ -1,16 +1,15 @@ import {div} from "@bokehjs/core/dom" import type * as p from "@bokehjs/core/properties" import {isNumber} from "@bokehjs/core/util/types" +import {LayoutDOM, LayoutDOMView} from "@bokehjs/models/layouts/layout_dom" import {ColumnDataSource} from "@bokehjs/models/sources/column_data_source" import {debounce} from "debounce" import {transform_cds_to_records} from "./data" -import {LayoutDOM, LayoutDOMView} from "@bokehjs/models/layouts/layout_dom" +import {GL} from "./lumagl" import {makeTooltip} from "./tooltips" -import GL from "@luma.gl/constants" - function extractClasses() { // Get classes for registration from standalone deck.gl const classesDict: any = {} diff --git a/panel/models/esm.py b/panel/models/esm.py new file mode 100644 index 0000000000..5308c55feb --- /dev/null +++ b/panel/models/esm.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from typing import Any + +import bokeh.core.properties as bp + +from bokeh.events import ModelEvent +from bokeh.model import DataModel + +from ..config import config +from ..io.resources import bundled_files +from ..util import classproperty +from .layout import HTMLBox + + +class ESMEvent(ModelEvent): + + event_name = 'esm_event' + + def __init__(self, model, data=None): + self.data = data + super().__init__(model=model) + + def event_values(self) -> dict[str, Any]: + return dict(super().event_values(), data=self.data) + + +class ReactiveESM(HTMLBox): + + children = bp.List(bp.String) + + data = bp.Instance(DataModel) + + dev = bp.Bool(False) + + esm = bp.String() + + importmap = bp.Dict(bp.String, bp.Dict(bp.String, bp.String)) + + __javascript_modules_raw__ = [ + f"{config.npm_cdn}/es-module-shims@^1.10.0/dist/es-module-shims.min.js" + ] + + @classproperty + def __javascript_modules__(cls): + return bundled_files(cls, 'javascript_modules') + + +class ReactComponent(ReactiveESM): + """ + Renders jsx/tsx based ESM bundles using React. + """ + + react_version = bp.String('18.3.1') + + +class AnyWidgetComponent(ReactComponent): + """ + Renders AnyWidget esm definitions by adding a compatibility layer. + """ diff --git a/panel/models/index.ts b/panel/models/index.ts index 4e448b9005..f6e656899c 100644 --- a/panel/models/index.ts +++ b/panel/models/index.ts @@ -1,4 +1,5 @@ export {AcePlot} from "./ace" +export {AnyWidgetComponent} from "./anywidget_component" export {Audio} from "./audio" export {BrowserInfo} from "./browser" export {Button} from "./button" @@ -32,7 +33,9 @@ export {PlotlyPlot} from "./plotly" export {Progress} from "./progress" export {QuillInput} from "./quill" export {RadioButtonGroup} from "./radio_button_group" +export {ReactComponent} from "./react_component" export {ReactiveHTML} from "./reactive_html" +export {ReactiveESM} from "./reactive_esm" export {SingleSelect} from "./singleselect" export {SpeechToText} from "./speech_to_text" export {State} from "./state" diff --git a/panel/models/lumagl.ts b/panel/models/lumagl.ts new file mode 100644 index 0000000000..df485bdce8 --- /dev/null +++ b/panel/models/lumagl.ts @@ -0,0 +1,1050 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +/* eslint-disable key-spacing, max-len, no-inline-comments, camelcase */ + +/** + * Standard WebGL, WebGL2 and extension constants (OpenGL constants) + * @note (Most) of these constants are also defined on the WebGLRenderingContext interface. + * @see https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/Constants + * @privateRemarks Locally called `GLEnum` instead of `GL`, because `babel-plugin-inline-webl-constants` + * both depends on and processes this module, but shouldn't replace these declarations. + */ +enum GLEnum { + // Clearing buffers + // Constants passed to clear() to clear buffer masks. + + /** Passed to clear to clear the current depth buffer. */ + DEPTH_BUFFER_BIT = 0x00000100, + /** Passed to clear to clear the current stencil buffer. */ + STENCIL_BUFFER_BIT = 0x00000400, + /** Passed to clear to clear the current color buffer. */ + COLOR_BUFFER_BIT = 0x00004000, + + // Rendering primitives + // Constants passed to drawElements() or drawArrays() to specify what kind of primitive to render. + + /** Passed to drawElements or drawArrays to draw single points. */ + POINTS = 0x0000, + /** Passed to drawElements or drawArrays to draw lines. Each vertex connects to the one after it. */ + LINES = 0x0001, + /** Passed to drawElements or drawArrays to draw lines. Each set of two vertices is treated as a separate line segment. */ + LINE_LOOP = 0x0002, + /** Passed to drawElements or drawArrays to draw a connected group of line segments from the first vertex to the last. */ + LINE_STRIP = 0x0003, + /** Passed to drawElements or drawArrays to draw triangles. Each set of three vertices creates a separate triangle. */ + TRIANGLES = 0x0004, + /** Passed to drawElements or drawArrays to draw a connected group of triangles. */ + TRIANGLE_STRIP = 0x0005, + /** Passed to drawElements or drawArrays to draw a connected group of triangles. Each vertex connects to the previous and the first vertex in the fan. */ + TRIANGLE_FAN = 0x0006, + + // Blending modes + // Constants passed to blendFunc() or blendFuncSeparate() to specify the blending mode (for both, RBG and alpha, or separately). + /** Passed to blendFunc or blendFuncSeparate to turn off a component. */ + ZERO = 0, + /** Passed to blendFunc or blendFuncSeparate to turn on a component. */ + ONE = 1, + /** Passed to blendFunc or blendFuncSeparate to multiply a component by the source elements color. */ + SRC_COLOR = 0x0300, + /** Passed to blendFunc or blendFuncSeparate to multiply a component by one minus the source elements color. */ + ONE_MINUS_SRC_COLOR = 0x0301, + /** Passed to blendFunc or blendFuncSeparate to multiply a component by the source's alpha. */ + SRC_ALPHA = 0x0302, + /** Passed to blendFunc or blendFuncSeparate to multiply a component by one minus the source's alpha. */ + ONE_MINUS_SRC_ALPHA = 0x0303, + /** Passed to blendFunc or blendFuncSeparate to multiply a component by the destination's alpha. */ + DST_ALPHA = 0x0304, + /** Passed to blendFunc or blendFuncSeparate to multiply a component by one minus the destination's alpha. */ + ONE_MINUS_DST_ALPHA = 0x0305, + /** Passed to blendFunc or blendFuncSeparate to multiply a component by the destination's color. */ + DST_COLOR = 0x0306, + /** Passed to blendFunc or blendFuncSeparate to multiply a component by one minus the destination's color. */ + ONE_MINUS_DST_COLOR = 0x0307, + /** Passed to blendFunc or blendFuncSeparate to multiply a component by the minimum of source's alpha or one minus the destination's alpha. */ + SRC_ALPHA_SATURATE = 0x0308, + /** Passed to blendFunc or blendFuncSeparate to specify a constant color blend function. */ + CONSTANT_COLOR = 0x8001, + /** Passed to blendFunc or blendFuncSeparate to specify one minus a constant color blend function. */ + ONE_MINUS_CONSTANT_COLOR = 0x8002, + /** Passed to blendFunc or blendFuncSeparate to specify a constant alpha blend function. */ + CONSTANT_ALPHA = 0x8003, + /** Passed to blendFunc or blendFuncSeparate to specify one minus a constant alpha blend function. */ + ONE_MINUS_CONSTANT_ALPHA = 0x8004, + + // Blending equations + // Constants passed to blendEquation() or blendEquationSeparate() to control + // how the blending is calculated (for both, RBG and alpha, or separately). + + /** Passed to blendEquation or blendEquationSeparate to set an addition blend function. */ + /** Passed to blendEquation or blendEquationSeparate to specify a subtraction blend function (source - destination). */ + /** Passed to blendEquation or blendEquationSeparate to specify a reverse subtraction blend function (destination - source). */ + FUNC_ADD = 0x8006, + FUNC_SUBTRACT = 0x800a, + FUNC_REVERSE_SUBTRACT = 0x800b, + + // Getting GL parameter information + // Constants passed to getParameter() to specify what information to return. + + /** Passed to getParameter to get the current RGB blend function. */ + BLEND_EQUATION = 0x8009, + /** Passed to getParameter to get the current RGB blend function. Same as BLEND_EQUATION */ + BLEND_EQUATION_RGB = 0x8009, + /** Passed to getParameter to get the current alpha blend function. Same as BLEND_EQUATION */ + BLEND_EQUATION_ALPHA = 0x883d, + /** Passed to getParameter to get the current destination RGB blend function. */ + BLEND_DST_RGB = 0x80c8, + /** Passed to getParameter to get the current destination RGB blend function. */ + BLEND_SRC_RGB = 0x80c9, + /** Passed to getParameter to get the current destination alpha blend function. */ + BLEND_DST_ALPHA = 0x80ca, + /** Passed to getParameter to get the current source alpha blend function. */ + BLEND_SRC_ALPHA = 0x80cb, + + /** Passed to getParameter to return a the current blend color. */ + BLEND_COLOR = 0x8005, + /** Passed to getParameter to get the array buffer binding. */ + ARRAY_BUFFER_BINDING = 0x8894, + /** Passed to getParameter to get the current element array buffer. */ + ELEMENT_ARRAY_BUFFER_BINDING = 0x8895, + /** Passed to getParameter to get the current lineWidth (set by the lineWidth method). */ + LINE_WIDTH = 0x0b21, + /** Passed to getParameter to get the current size of a point drawn with gl.POINTS */ + ALIASED_POINT_SIZE_RANGE = 0x846d, + /** Passed to getParameter to get the range of available widths for a line. Returns a length-2 array with the lo value at 0, and high at 1. */ + ALIASED_LINE_WIDTH_RANGE = 0x846e, + /** Passed to getParameter to get the current value of cullFace. Should return FRONT, BACK, or FRONT_AND_BACK */ + CULL_FACE_MODE = 0x0b45, + /** Passed to getParameter to determine the current value of frontFace. Should return CW or CCW. */ + FRONT_FACE = 0x0b46, + /** Passed to getParameter to return a length-2 array of floats giving the current depth range. */ + DEPTH_RANGE = 0x0b70, + /** Passed to getParameter to determine if the depth write mask is enabled. */ + + DEPTH_WRITEMASK = 0x0b72, + /** Passed to getParameter to determine the current depth clear value. */ + DEPTH_CLEAR_VALUE = 0x0b73, + /** Passed to getParameter to get the current depth function. Returns NEVER, ALWAYS, LESS, EQUAL, LEQUAL, GREATER, GEQUAL, or NOTEQUAL. */ + DEPTH_FUNC = 0x0b74, + /** Passed to getParameter to get the value the stencil will be cleared to. */ + STENCIL_CLEAR_VALUE = 0x0b91, + /** Passed to getParameter to get the current stencil function. Returns NEVER, ALWAYS, LESS, EQUAL, LEQUAL, GREATER, GEQUAL, or NOTEQUAL. */ + STENCIL_FUNC = 0x0b92, + /** Passed to getParameter to get the current stencil fail function. Should return KEEP, REPLACE, INCR, DECR, INVERT, INCR_WRAP, or DECR_WRAP. */ + STENCIL_FAIL = 0x0b94, + /** Passed to getParameter to get the current stencil fail function should the depth buffer test fail. Should return KEEP, REPLACE, INCR, DECR, INVERT, INCR_WRAP, or DECR_WRAP. */ + STENCIL_PASS_DEPTH_FAIL = 0x0b95, + /** Passed to getParameter to get the current stencil fail function should the depth buffer test pass. Should return KEEP, REPLACE, INCR, DECR, INVERT, INCR_WRAP, or DECR_WRAP. */ + STENCIL_PASS_DEPTH_PASS = 0x0b96, + /** Passed to getParameter to get the reference value used for stencil tests. */ + STENCIL_REF = 0x0b97, + STENCIL_VALUE_MASK = 0x0b93, + STENCIL_WRITEMASK = 0x0b98, + STENCIL_BACK_FUNC = 0x8800, + STENCIL_BACK_FAIL = 0x8801, + STENCIL_BACK_PASS_DEPTH_FAIL = 0x8802, + STENCIL_BACK_PASS_DEPTH_PASS = 0x8803, + STENCIL_BACK_REF = 0x8ca3, + STENCIL_BACK_VALUE_MASK = 0x8ca4, + STENCIL_BACK_WRITEMASK = 0x8ca5, + + /** An Int32Array with four elements for the current viewport dimensions. */ + VIEWPORT = 0x0ba2, + /** An Int32Array with four elements for the current scissor box dimensions. */ + SCISSOR_BOX = 0x0c10, + COLOR_CLEAR_VALUE = 0x0c22, + COLOR_WRITEMASK = 0x0c23, + UNPACK_ALIGNMENT = 0x0cf5, + PACK_ALIGNMENT = 0x0d05, + MAX_TEXTURE_SIZE = 0x0d33, + MAX_VIEWPORT_DIMS = 0x0d3a, + SUBPIXEL_BITS = 0x0d50, + RED_BITS = 0x0d52, + GREEN_BITS = 0x0d53, + BLUE_BITS = 0x0d54, + ALPHA_BITS = 0x0d55, + DEPTH_BITS = 0x0d56, + STENCIL_BITS = 0x0d57, + POLYGON_OFFSET_UNITS = 0x2a00, + POLYGON_OFFSET_FACTOR = 0x8038, + TEXTURE_BINDING_2D = 0x8069, + SAMPLE_BUFFERS = 0x80a8, + SAMPLES = 0x80a9, + SAMPLE_COVERAGE_VALUE = 0x80aa, + SAMPLE_COVERAGE_INVERT = 0x80ab, + COMPRESSED_TEXTURE_FORMATS = 0x86a3, + VENDOR = 0x1f00, + RENDERER = 0x1f01, + VERSION = 0x1f02, + IMPLEMENTATION_COLOR_READ_TYPE = 0x8b9a, + IMPLEMENTATION_COLOR_READ_FORMAT = 0x8b9b, + BROWSER_DEFAULT_WEBGL = 0x9244, + + // Buffers + // Constants passed to bufferData(), bufferSubData(), bindBuffer(), or + // getBufferParameter(). + + /** Passed to bufferData as a hint about whether the contents of the buffer are likely to be used often and not change often. */ + STATIC_DRAW = 0x88e4, + /** Passed to bufferData as a hint about whether the contents of the buffer are likely to not be used often. */ + STREAM_DRAW = 0x88e0, + /** Passed to bufferData as a hint about whether the contents of the buffer are likely to be used often and change often. */ + DYNAMIC_DRAW = 0x88e8, + /** Passed to bindBuffer or bufferData to specify the type of buffer being used. */ + ARRAY_BUFFER = 0x8892, + /** Passed to bindBuffer or bufferData to specify the type of buffer being used. */ + ELEMENT_ARRAY_BUFFER = 0x8893, + /** Passed to getBufferParameter to get a buffer's size. */ + BUFFER_SIZE = 0x8764, + /** Passed to getBufferParameter to get the hint for the buffer passed in when it was created. */ + BUFFER_USAGE = 0x8765, + + // Vertex attributes + // Constants passed to getVertexAttrib(). + + /** Passed to getVertexAttrib to read back the current vertex attribute. */ + CURRENT_VERTEX_ATTRIB = 0x8626, + VERTEX_ATTRIB_ARRAY_ENABLED = 0x8622, + VERTEX_ATTRIB_ARRAY_SIZE = 0x8623, + VERTEX_ATTRIB_ARRAY_STRIDE = 0x8624, + VERTEX_ATTRIB_ARRAY_TYPE = 0x8625, + VERTEX_ATTRIB_ARRAY_NORMALIZED = 0x886a, + VERTEX_ATTRIB_ARRAY_POINTER = 0x8645, + VERTEX_ATTRIB_ARRAY_BUFFER_BINDING = 0x889f, + + // Culling + // Constants passed to cullFace(). + + /** Passed to enable/disable to turn on/off culling. Can also be used with getParameter to find the current culling method. */ + CULL_FACE = 0x0b44, + /** Passed to cullFace to specify that only front faces should be culled. */ + FRONT = 0x0404, + /** Passed to cullFace to specify that only back faces should be culled. */ + BACK = 0x0405, + /** Passed to cullFace to specify that front and back faces should be culled. */ + FRONT_AND_BACK = 0x0408, + + // Enabling and disabling + // Constants passed to enable() or disable(). + + /** Passed to enable/disable to turn on/off blending. Can also be used with getParameter to find the current blending method. */ + BLEND = 0x0be2, + /** Passed to enable/disable to turn on/off the depth test. Can also be used with getParameter to query the depth test. */ + DEPTH_TEST = 0x0b71, + /** Passed to enable/disable to turn on/off dithering. Can also be used with getParameter to find the current dithering method. */ + DITHER = 0x0bd0, + /** Passed to enable/disable to turn on/off the polygon offset. Useful for rendering hidden-line images, decals, and or solids with highlighted edges. Can also be used with getParameter to query the scissor test. */ + POLYGON_OFFSET_FILL = 0x8037, + /** Passed to enable/disable to turn on/off the alpha to coverage. Used in multi-sampling alpha channels. */ + SAMPLE_ALPHA_TO_COVERAGE = 0x809e, + /** Passed to enable/disable to turn on/off the sample coverage. Used in multi-sampling. */ + SAMPLE_COVERAGE = 0x80a0, + /** Passed to enable/disable to turn on/off the scissor test. Can also be used with getParameter to query the scissor test. */ + SCISSOR_TEST = 0x0c11, + /** Passed to enable/disable to turn on/off the stencil test. Can also be used with getParameter to query the stencil test. */ + STENCIL_TEST = 0x0b90, + + // Errors + // Constants returned from getError(). + + /** Returned from getError(). */ + NO_ERROR = 0, + /** Returned from getError(). */ + INVALID_ENUM = 0x0500, + /** Returned from getError(). */ + INVALID_VALUE = 0x0501, + /** Returned from getError(). */ + INVALID_OPERATION = 0x0502, + /** Returned from getError(). */ + OUT_OF_MEMORY = 0x0505, + /** Returned from getError(). */ + CONTEXT_LOST_WEBGL = 0x9242, + + // Front face directions + // Constants passed to frontFace(). + + /** Passed to frontFace to specify the front face of a polygon is drawn in the clockwise direction */ + CW = 0x0900, + /** Passed to frontFace to specify the front face of a polygon is drawn in the counter clockwise direction */ + CCW = 0x0901, + + // Hints + // Constants passed to hint() + + /** There is no preference for this behavior. */ + DONT_CARE = 0x1100, + /** The most efficient behavior should be used. */ + FASTEST = 0x1101, + /** The most correct or the highest quality option should be used. */ + NICEST = 0x1102, + /** Hint for the quality of filtering when generating mipmap images with WebGLRenderingContext.generateMipmap(). */ + GENERATE_MIPMAP_HINT = 0x8192, + + // Data types + + BYTE = 0x1400, + UNSIGNED_BYTE = 0x1401, + SHORT = 0x1402, + UNSIGNED_SHORT = 0x1403, + INT = 0x1404, + UNSIGNED_INT = 0x1405, + FLOAT = 0x1406, + DOUBLE = 0x140a, + + // Pixel formats + + DEPTH_COMPONENT = 0x1902, + ALPHA = 0x1906, + RGB = 0x1907, + RGBA = 0x1908, + LUMINANCE = 0x1909, + LUMINANCE_ALPHA = 0x190a, + + // Pixel types + + // UNSIGNED_BYTE = 0x1401, + UNSIGNED_SHORT_4_4_4_4 = 0x8033, + UNSIGNED_SHORT_5_5_5_1 = 0x8034, + UNSIGNED_SHORT_5_6_5 = 0x8363, + + // Shaders + // Constants passed to createShader() or getShaderParameter() + + /** Passed to createShader to define a fragment shader. */ + FRAGMENT_SHADER = 0x8b30, + /** Passed to createShader to define a vertex shader */ + VERTEX_SHADER = 0x8b31, + /** Passed to getShaderParameter to get the status of the compilation. Returns false if the shader was not compiled. You can then query getShaderInfoLog to find the exact error */ + COMPILE_STATUS = 0x8b81, + /** Passed to getShaderParameter to determine if a shader was deleted via deleteShader. Returns true if it was, false otherwise. */ + DELETE_STATUS = 0x8b80, + /** Passed to getProgramParameter after calling linkProgram to determine if a program was linked correctly. Returns false if there were errors. Use getProgramInfoLog to find the exact error. */ + LINK_STATUS = 0x8b82, + /** Passed to getProgramParameter after calling validateProgram to determine if it is valid. Returns false if errors were found. */ + VALIDATE_STATUS = 0x8b83, + /** Passed to getProgramParameter after calling attachShader to determine if the shader was attached correctly. Returns false if errors occurred. */ + ATTACHED_SHADERS = 0x8b85, + /** Passed to getProgramParameter to get the number of attributes active in a program. */ + ACTIVE_ATTRIBUTES = 0x8b89, + /** Passed to getProgramParameter to get the number of uniforms active in a program. */ + ACTIVE_UNIFORMS = 0x8b86, + /** The maximum number of entries possible in the vertex attribute list. */ + MAX_VERTEX_ATTRIBS = 0x8869, + MAX_VERTEX_UNIFORM_VECTORS = 0x8dfb, + MAX_VARYING_VECTORS = 0x8dfc, + MAX_COMBINED_TEXTURE_IMAGE_UNITS = 0x8b4d, + MAX_VERTEX_TEXTURE_IMAGE_UNITS = 0x8b4c, + /** Implementation dependent number of maximum texture units. At least 8. */ + MAX_TEXTURE_IMAGE_UNITS = 0x8872, + MAX_FRAGMENT_UNIFORM_VECTORS = 0x8dfd, + SHADER_TYPE = 0x8b4f, + SHADING_LANGUAGE_VERSION = 0x8b8c, + CURRENT_PROGRAM = 0x8b8d, + + // Depth or stencil tests + // Constants passed to depthFunc() or stencilFunc(). + + /** Passed to depthFunction or stencilFunction to specify depth or stencil tests will never pass, i.e., nothing will be drawn. */ + NEVER = 0x0200, + /** Passed to depthFunction or stencilFunction to specify depth or stencil tests will pass if the new depth value is less than the stored value. */ + LESS = 0x0201, + /** Passed to depthFunction or stencilFunction to specify depth or stencil tests will pass if the new depth value is equals to the stored value. */ + EQUAL = 0x0202, + /** Passed to depthFunction or stencilFunction to specify depth or stencil tests will pass if the new depth value is less than or equal to the stored value. */ + LEQUAL = 0x0203, + /** Passed to depthFunction or stencilFunction to specify depth or stencil tests will pass if the new depth value is greater than the stored value. */ + GREATER = 0x0204, + /** Passed to depthFunction or stencilFunction to specify depth or stencil tests will pass if the new depth value is not equal to the stored value. */ + NOTEQUAL = 0x0205, + /** Passed to depthFunction or stencilFunction to specify depth or stencil tests will pass if the new depth value is greater than or equal to the stored value. */ + GEQUAL = 0x0206, + /** Passed to depthFunction or stencilFunction to specify depth or stencil tests will always pass, i.e., pixels will be drawn in the order they are drawn. */ + ALWAYS = 0x0207, + + // Stencil actions + // Constants passed to stencilOp(). + + KEEP = 0x1e00, + REPLACE = 0x1e01, + INCR = 0x1e02, + DECR = 0x1e03, + INVERT = 0x150a, + INCR_WRAP = 0x8507, + DECR_WRAP = 0x8508, + + // Textures + // Constants passed to texParameteri(), + // texParameterf(), bindTexture(), texImage2D(), and others. + + NEAREST = 0x2600, + LINEAR = 0x2601, + NEAREST_MIPMAP_NEAREST = 0x2700, + LINEAR_MIPMAP_NEAREST = 0x2701, + NEAREST_MIPMAP_LINEAR = 0x2702, + LINEAR_MIPMAP_LINEAR = 0x2703, + /** The texture magnification function is used when the pixel being textured maps to an area less than or equal to one texture element. It sets the texture magnification function to either GL_NEAREST or GL_LINEAR (see below). GL_NEAREST is generally faster than GL_LINEAR, but it can produce textured images with sharper edges because the transition between texture elements is not as smooth. Default: GL_LINEAR. */ + TEXTURE_MAG_FILTER = 0x2800, + /** The texture minifying function is used whenever the pixel being textured maps to an area greater than one texture element. There are six defined minifying functions. Two of them use the nearest one or nearest four texture elements to compute the texture value. The other four use mipmaps. Default: GL_NEAREST_MIPMAP_LINEAR */ + TEXTURE_MIN_FILTER = 0x2801, + /** Sets the wrap parameter for texture coordinate to either GL_CLAMP_TO_EDGE, GL_MIRRORED_REPEAT, or GL_REPEAT. G */ + TEXTURE_WRAP_S = 0x2802, + /** Sets the wrap parameter for texture coordinate to either GL_CLAMP_TO_EDGE, GL_MIRRORED_REPEAT, or GL_REPEAT. G */ + TEXTURE_WRAP_T = 0x2803, + TEXTURE_2D = 0x0de1, + TEXTURE = 0x1702, + TEXTURE_CUBE_MAP = 0x8513, + TEXTURE_BINDING_CUBE_MAP = 0x8514, + TEXTURE_CUBE_MAP_POSITIVE_X = 0x8515, + TEXTURE_CUBE_MAP_NEGATIVE_X = 0x8516, + TEXTURE_CUBE_MAP_POSITIVE_Y = 0x8517, + TEXTURE_CUBE_MAP_NEGATIVE_Y = 0x8518, + TEXTURE_CUBE_MAP_POSITIVE_Z = 0x8519, + TEXTURE_CUBE_MAP_NEGATIVE_Z = 0x851a, + MAX_CUBE_MAP_TEXTURE_SIZE = 0x851c, + // TEXTURE0 - 31 0x84C0 - 0x84DF A texture unit. + TEXTURE0 = 0x84c0, + ACTIVE_TEXTURE = 0x84e0, + REPEAT = 0x2901, + CLAMP_TO_EDGE = 0x812f, + MIRRORED_REPEAT = 0x8370, + + // Emulation + TEXTURE_WIDTH = 0x1000, + TEXTURE_HEIGHT = 0x1001, + + // Uniform types + + FLOAT_VEC2 = 0x8b50, + FLOAT_VEC3 = 0x8b51, + FLOAT_VEC4 = 0x8b52, + INT_VEC2 = 0x8b53, + INT_VEC3 = 0x8b54, + INT_VEC4 = 0x8b55, + BOOL = 0x8b56, + BOOL_VEC2 = 0x8b57, + BOOL_VEC3 = 0x8b58, + BOOL_VEC4 = 0x8b59, + FLOAT_MAT2 = 0x8b5a, + FLOAT_MAT3 = 0x8b5b, + FLOAT_MAT4 = 0x8b5c, + SAMPLER_2D = 0x8b5e, + SAMPLER_CUBE = 0x8b60, + + // Shader precision-specified types + + LOW_FLOAT = 0x8df0, + MEDIUM_FLOAT = 0x8df1, + HIGH_FLOAT = 0x8df2, + LOW_INT = 0x8df3, + MEDIUM_INT = 0x8df4, + HIGH_INT = 0x8df5, + + // Framebuffers and renderbuffers + + FRAMEBUFFER = 0x8d40, + RENDERBUFFER = 0x8d41, + RGBA4 = 0x8056, + RGB5_A1 = 0x8057, + RGB565 = 0x8d62, + DEPTH_COMPONENT16 = 0x81a5, + STENCIL_INDEX = 0x1901, + STENCIL_INDEX8 = 0x8d48, + DEPTH_STENCIL = 0x84f9, + RENDERBUFFER_WIDTH = 0x8d42, + RENDERBUFFER_HEIGHT = 0x8d43, + RENDERBUFFER_INTERNAL_FORMAT = 0x8d44, + RENDERBUFFER_RED_SIZE = 0x8d50, + RENDERBUFFER_GREEN_SIZE = 0x8d51, + RENDERBUFFER_BLUE_SIZE = 0x8d52, + RENDERBUFFER_ALPHA_SIZE = 0x8d53, + RENDERBUFFER_DEPTH_SIZE = 0x8d54, + RENDERBUFFER_STENCIL_SIZE = 0x8d55, + FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE = 0x8cd0, + FRAMEBUFFER_ATTACHMENT_OBJECT_NAME = 0x8cd1, + FRAMEBUFFER_ATTACHMENT_TEXTURE_LEVEL = 0x8cd2, + FRAMEBUFFER_ATTACHMENT_TEXTURE_CUBE_MAP_FACE = 0x8cd3, + COLOR_ATTACHMENT0 = 0x8ce0, + DEPTH_ATTACHMENT = 0x8d00, + STENCIL_ATTACHMENT = 0x8d20, + DEPTH_STENCIL_ATTACHMENT = 0x821a, + NONE = 0, + FRAMEBUFFER_COMPLETE = 0x8cd5, + FRAMEBUFFER_INCOMPLETE_ATTACHMENT = 0x8cd6, + FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT = 0x8cd7, + FRAMEBUFFER_INCOMPLETE_DIMENSIONS = 0x8cd9, + FRAMEBUFFER_UNSUPPORTED = 0x8cdd, + FRAMEBUFFER_BINDING = 0x8ca6, + RENDERBUFFER_BINDING = 0x8ca7, + READ_FRAMEBUFFER = 0x8ca8, + DRAW_FRAMEBUFFER = 0x8ca9, + MAX_RENDERBUFFER_SIZE = 0x84e8, + INVALID_FRAMEBUFFER_OPERATION = 0x0506, + + // Pixel storage modes + // Constants passed to pixelStorei(). + + UNPACK_FLIP_Y_WEBGL = 0x9240, + UNPACK_PREMULTIPLY_ALPHA_WEBGL = 0x9241, + UNPACK_COLORSPACE_CONVERSION_WEBGL = 0x9243, + + // Additional constants defined WebGL 2 + // These constants are defined on the WebGL2RenderingContext interface. + // All WebGL 1 constants are also available in a WebGL 2 context. + + // Getting GL parameter information + // Constants passed to getParameter() + // to specify what information to return. + + READ_BUFFER = 0x0c02, + UNPACK_ROW_LENGTH = 0x0cf2, + UNPACK_SKIP_ROWS = 0x0cf3, + UNPACK_SKIP_PIXELS = 0x0cf4, + PACK_ROW_LENGTH = 0x0d02, + PACK_SKIP_ROWS = 0x0d03, + PACK_SKIP_PIXELS = 0x0d04, + TEXTURE_BINDING_3D = 0x806a, + UNPACK_SKIP_IMAGES = 0x806d, + UNPACK_IMAGE_HEIGHT = 0x806e, + MAX_3D_TEXTURE_SIZE = 0x8073, + MAX_ELEMENTS_VERTICES = 0x80e8, + MAX_ELEMENTS_INDICES = 0x80e9, + MAX_TEXTURE_LOD_BIAS = 0x84fd, + MAX_FRAGMENT_UNIFORM_COMPONENTS = 0x8b49, + MAX_VERTEX_UNIFORM_COMPONENTS = 0x8b4a, + MAX_ARRAY_TEXTURE_LAYERS = 0x88ff, + MIN_PROGRAM_TEXEL_OFFSET = 0x8904, + MAX_PROGRAM_TEXEL_OFFSET = 0x8905, + MAX_VARYING_COMPONENTS = 0x8b4b, + FRAGMENT_SHADER_DERIVATIVE_HINT = 0x8b8b, + RASTERIZER_DISCARD = 0x8c89, + VERTEX_ARRAY_BINDING = 0x85b5, + MAX_VERTEX_OUTPUT_COMPONENTS = 0x9122, + MAX_FRAGMENT_INPUT_COMPONENTS = 0x9125, + MAX_SERVER_WAIT_TIMEOUT = 0x9111, + MAX_ELEMENT_INDEX = 0x8d6b, + + // Textures + // Constants passed to texParameteri(), + // texParameterf(), bindTexture(), texImage2D(), and others. + + RED = 0x1903, + RGB8 = 0x8051, + RGBA8 = 0x8058, + RGB10_A2 = 0x8059, + TEXTURE_3D = 0x806f, + /** Sets the wrap parameter for texture coordinate to either GL_CLAMP_TO_EDGE, GL_MIRRORED_REPEAT, or GL_REPEAT. G */ + TEXTURE_WRAP_R = 0x8072, + TEXTURE_MIN_LOD = 0x813a, + TEXTURE_MAX_LOD = 0x813b, + TEXTURE_BASE_LEVEL = 0x813c, + TEXTURE_MAX_LEVEL = 0x813d, + TEXTURE_COMPARE_MODE = 0x884c, + TEXTURE_COMPARE_FUNC = 0x884d, + SRGB = 0x8c40, + SRGB8 = 0x8c41, + SRGB8_ALPHA8 = 0x8c43, + COMPARE_REF_TO_TEXTURE = 0x884e, + RGBA32F = 0x8814, + RGB32F = 0x8815, + RGBA16F = 0x881a, + RGB16F = 0x881b, + TEXTURE_2D_ARRAY = 0x8c1a, + TEXTURE_BINDING_2D_ARRAY = 0x8c1d, + R11F_G11F_B10F = 0x8c3a, + RGB9_E5 = 0x8c3d, + RGBA32UI = 0x8d70, + RGB32UI = 0x8d71, + RGBA16UI = 0x8d76, + RGB16UI = 0x8d77, + RGBA8UI = 0x8d7c, + RGB8UI = 0x8d7d, + RGBA32I = 0x8d82, + RGB32I = 0x8d83, + RGBA16I = 0x8d88, + RGB16I = 0x8d89, + RGBA8I = 0x8d8e, + RGB8I = 0x8d8f, + RED_INTEGER = 0x8d94, + RGB_INTEGER = 0x8d98, + RGBA_INTEGER = 0x8d99, + R8 = 0x8229, + RG8 = 0x822b, + R16F = 0x822d, + R32F = 0x822e, + RG16F = 0x822f, + RG32F = 0x8230, + R8I = 0x8231, + R8UI = 0x8232, + R16I = 0x8233, + R16UI = 0x8234, + R32I = 0x8235, + R32UI = 0x8236, + RG8I = 0x8237, + RG8UI = 0x8238, + RG16I = 0x8239, + RG16UI = 0x823a, + RG32I = 0x823b, + RG32UI = 0x823c, + R8_SNORM = 0x8f94, + RG8_SNORM = 0x8f95, + RGB8_SNORM = 0x8f96, + RGBA8_SNORM = 0x8f97, + RGB10_A2UI = 0x906f, + + /* covered by extension + COMPRESSED_R11_EAC = 0x9270, + COMPRESSED_SIGNED_R11_EAC = 0x9271, + COMPRESSED_RG11_EAC = 0x9272, + COMPRESSED_SIGNED_RG11_EAC = 0x9273, + COMPRESSED_RGB8_ETC2 = 0x9274, + COMPRESSED_SRGB8_ETC2 = 0x9275, + COMPRESSED_RGB8_PUNCHTHROUGH_ALPHA1_ETC2 = 0x9276, + COMPRESSED_SRGB8_PUNCHTHROUGH_ALPHA1_ETC = 0x9277, + COMPRESSED_RGBA8_ETC2_EAC = 0x9278, + COMPRESSED_SRGB8_ALPHA8_ETC2_EAC = 0x9279, + */ + TEXTURE_IMMUTABLE_FORMAT = 0x912f, + TEXTURE_IMMUTABLE_LEVELS = 0x82df, + + // Pixel types + + UNSIGNED_INT_2_10_10_10_REV = 0x8368, + UNSIGNED_INT_10F_11F_11F_REV = 0x8c3b, + UNSIGNED_INT_5_9_9_9_REV = 0x8c3e, + FLOAT_32_UNSIGNED_INT_24_8_REV = 0x8dad, + UNSIGNED_INT_24_8 = 0x84fa, + HALF_FLOAT = 0x140b, + RG = 0x8227, + RG_INTEGER = 0x8228, + INT_2_10_10_10_REV = 0x8d9f, + + // Queries + + CURRENT_QUERY = 0x8865, + /** Returns a GLuint containing the query result. */ + QUERY_RESULT = 0x8866, + /** Whether query result is available. */ + QUERY_RESULT_AVAILABLE = 0x8867, + /** Occlusion query (if drawing passed depth test) */ + ANY_SAMPLES_PASSED = 0x8c2f, + /** Occlusion query less accurate/faster version */ + ANY_SAMPLES_PASSED_CONSERVATIVE = 0x8d6a, + + // Draw buffers + + MAX_DRAW_BUFFERS = 0x8824, + DRAW_BUFFER0 = 0x8825, + DRAW_BUFFER1 = 0x8826, + DRAW_BUFFER2 = 0x8827, + DRAW_BUFFER3 = 0x8828, + DRAW_BUFFER4 = 0x8829, + DRAW_BUFFER5 = 0x882a, + DRAW_BUFFER6 = 0x882b, + DRAW_BUFFER7 = 0x882c, + DRAW_BUFFER8 = 0x882d, + DRAW_BUFFER9 = 0x882e, + DRAW_BUFFER10 = 0x882f, + DRAW_BUFFER11 = 0x8830, + DRAW_BUFFER12 = 0x8831, + DRAW_BUFFER13 = 0x8832, + DRAW_BUFFER14 = 0x8833, + DRAW_BUFFER15 = 0x8834, + MAX_COLOR_ATTACHMENTS = 0x8cdf, + COLOR_ATTACHMENT1 = 0x8ce1, + COLOR_ATTACHMENT2 = 0x8ce2, + COLOR_ATTACHMENT3 = 0x8ce3, + COLOR_ATTACHMENT4 = 0x8ce4, + COLOR_ATTACHMENT5 = 0x8ce5, + COLOR_ATTACHMENT6 = 0x8ce6, + COLOR_ATTACHMENT7 = 0x8ce7, + COLOR_ATTACHMENT8 = 0x8ce8, + COLOR_ATTACHMENT9 = 0x8ce9, + COLOR_ATTACHMENT10 = 0x8cea, + COLOR_ATTACHMENT11 = 0x8ceb, + COLOR_ATTACHMENT12 = 0x8cec, + COLOR_ATTACHMENT13 = 0x8ced, + COLOR_ATTACHMENT14 = 0x8cee, + COLOR_ATTACHMENT15 = 0x8cef, + + // Samplers + + SAMPLER_3D = 0x8b5f, + SAMPLER_2D_SHADOW = 0x8b62, + SAMPLER_2D_ARRAY = 0x8dc1, + SAMPLER_2D_ARRAY_SHADOW = 0x8dc4, + SAMPLER_CUBE_SHADOW = 0x8dc5, + INT_SAMPLER_2D = 0x8dca, + INT_SAMPLER_3D = 0x8dcb, + INT_SAMPLER_CUBE = 0x8dcc, + INT_SAMPLER_2D_ARRAY = 0x8dcf, + UNSIGNED_INT_SAMPLER_2D = 0x8dd2, + UNSIGNED_INT_SAMPLER_3D = 0x8dd3, + UNSIGNED_INT_SAMPLER_CUBE = 0x8dd4, + UNSIGNED_INT_SAMPLER_2D_ARRAY = 0x8dd7, + MAX_SAMPLES = 0x8d57, + SAMPLER_BINDING = 0x8919, + + // Buffers + + PIXEL_PACK_BUFFER = 0x88eb, + PIXEL_UNPACK_BUFFER = 0x88ec, + PIXEL_PACK_BUFFER_BINDING = 0x88ed, + PIXEL_UNPACK_BUFFER_BINDING = 0x88ef, + COPY_READ_BUFFER = 0x8f36, + COPY_WRITE_BUFFER = 0x8f37, + COPY_READ_BUFFER_BINDING = 0x8f36, + COPY_WRITE_BUFFER_BINDING = 0x8f37, + + // Data types + + FLOAT_MAT2x3 = 0x8b65, + FLOAT_MAT2x4 = 0x8b66, + FLOAT_MAT3x2 = 0x8b67, + FLOAT_MAT3x4 = 0x8b68, + FLOAT_MAT4x2 = 0x8b69, + FLOAT_MAT4x3 = 0x8b6a, + UNSIGNED_INT_VEC2 = 0x8dc6, + UNSIGNED_INT_VEC3 = 0x8dc7, + UNSIGNED_INT_VEC4 = 0x8dc8, + UNSIGNED_NORMALIZED = 0x8c17, + SIGNED_NORMALIZED = 0x8f9c, + + // Vertex attributes + + VERTEX_ATTRIB_ARRAY_INTEGER = 0x88fd, + VERTEX_ATTRIB_ARRAY_DIVISOR = 0x88fe, + + // Transform feedback + + TRANSFORM_FEEDBACK_BUFFER_MODE = 0x8c7f, + MAX_TRANSFORM_FEEDBACK_SEPARATE_COMPONENTS = 0x8c80, + TRANSFORM_FEEDBACK_VARYINGS = 0x8c83, + TRANSFORM_FEEDBACK_BUFFER_START = 0x8c84, + TRANSFORM_FEEDBACK_BUFFER_SIZE = 0x8c85, + TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN = 0x8c88, + MAX_TRANSFORM_FEEDBACK_INTERLEAVED_COMPONENTS = 0x8c8a, + MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS = 0x8c8b, + INTERLEAVED_ATTRIBS = 0x8c8c, + SEPARATE_ATTRIBS = 0x8c8d, + TRANSFORM_FEEDBACK_BUFFER = 0x8c8e, + TRANSFORM_FEEDBACK_BUFFER_BINDING = 0x8c8f, + TRANSFORM_FEEDBACK = 0x8e22, + TRANSFORM_FEEDBACK_PAUSED = 0x8e23, + TRANSFORM_FEEDBACK_ACTIVE = 0x8e24, + TRANSFORM_FEEDBACK_BINDING = 0x8e25, + + // Framebuffers and renderbuffers + + FRAMEBUFFER_ATTACHMENT_COLOR_ENCODING = 0x8210, + FRAMEBUFFER_ATTACHMENT_COMPONENT_TYPE = 0x8211, + FRAMEBUFFER_ATTACHMENT_RED_SIZE = 0x8212, + FRAMEBUFFER_ATTACHMENT_GREEN_SIZE = 0x8213, + FRAMEBUFFER_ATTACHMENT_BLUE_SIZE = 0x8214, + FRAMEBUFFER_ATTACHMENT_ALPHA_SIZE = 0x8215, + FRAMEBUFFER_ATTACHMENT_DEPTH_SIZE = 0x8216, + FRAMEBUFFER_ATTACHMENT_STENCIL_SIZE = 0x8217, + FRAMEBUFFER_DEFAULT = 0x8218, + // DEPTH_STENCIL_ATTACHMENT = 0x821A, + // DEPTH_STENCIL = 0x84F9, + DEPTH24_STENCIL8 = 0x88f0, + DRAW_FRAMEBUFFER_BINDING = 0x8ca6, + READ_FRAMEBUFFER_BINDING = 0x8caa, + RENDERBUFFER_SAMPLES = 0x8cab, + FRAMEBUFFER_ATTACHMENT_TEXTURE_LAYER = 0x8cd4, + FRAMEBUFFER_INCOMPLETE_MULTISAMPLE = 0x8d56, + + // Uniforms + + UNIFORM_BUFFER = 0x8a11, + UNIFORM_BUFFER_BINDING = 0x8a28, + UNIFORM_BUFFER_START = 0x8a29, + UNIFORM_BUFFER_SIZE = 0x8a2a, + MAX_VERTEX_UNIFORM_BLOCKS = 0x8a2b, + MAX_FRAGMENT_UNIFORM_BLOCKS = 0x8a2d, + MAX_COMBINED_UNIFORM_BLOCKS = 0x8a2e, + MAX_UNIFORM_BUFFER_BINDINGS = 0x8a2f, + MAX_UNIFORM_BLOCK_SIZE = 0x8a30, + MAX_COMBINED_VERTEX_UNIFORM_COMPONENTS = 0x8a31, + MAX_COMBINED_FRAGMENT_UNIFORM_COMPONENTS = 0x8a33, + UNIFORM_BUFFER_OFFSET_ALIGNMENT = 0x8a34, + ACTIVE_UNIFORM_BLOCKS = 0x8a36, + UNIFORM_TYPE = 0x8a37, + UNIFORM_SIZE = 0x8a38, + UNIFORM_BLOCK_INDEX = 0x8a3a, + UNIFORM_OFFSET = 0x8a3b, + UNIFORM_ARRAY_STRIDE = 0x8a3c, + UNIFORM_MATRIX_STRIDE = 0x8a3d, + UNIFORM_IS_ROW_MAJOR = 0x8a3e, + UNIFORM_BLOCK_BINDING = 0x8a3f, + UNIFORM_BLOCK_DATA_SIZE = 0x8a40, + UNIFORM_BLOCK_ACTIVE_UNIFORMS = 0x8a42, + UNIFORM_BLOCK_ACTIVE_UNIFORM_INDICES = 0x8a43, + UNIFORM_BLOCK_REFERENCED_BY_VERTEX_SHADER = 0x8a44, + UNIFORM_BLOCK_REFERENCED_BY_FRAGMENT_SHADER = 0x8a46, + + // Sync objects + + OBJECT_TYPE = 0x9112, + SYNC_CONDITION = 0x9113, + SYNC_STATUS = 0x9114, + SYNC_FLAGS = 0x9115, + SYNC_FENCE = 0x9116, + SYNC_GPU_COMMANDS_COMPLETE = 0x9117, + UNSIGNALED = 0x9118, + SIGNALED = 0x9119, + ALREADY_SIGNALED = 0x911a, + TIMEOUT_EXPIRED = 0x911b, + CONDITION_SATISFIED = 0x911c, + WAIT_FAILED = 0x911d, + SYNC_FLUSH_COMMANDS_BIT = 0x00000001, + + // Miscellaneous constants + + COLOR = 0x1800, + DEPTH = 0x1801, + STENCIL = 0x1802, + MIN = 0x8007, + MAX = 0x8008, + DEPTH_COMPONENT24 = 0x81a6, + STREAM_READ = 0x88e1, + STREAM_COPY = 0x88e2, + STATIC_READ = 0x88e5, + STATIC_COPY = 0x88e6, + DYNAMIC_READ = 0x88e9, + DYNAMIC_COPY = 0x88ea, + DEPTH_COMPONENT32F = 0x8cac, + DEPTH32F_STENCIL8 = 0x8cad, + INVALID_INDEX = 0xffffffff, + TIMEOUT_IGNORED = -1, + MAX_CLIENT_WAIT_TIMEOUT_WEBGL = 0x9247, + + // Constants defined in WebGL extensions + + // WEBGL_debug_renderer_info + + /** Passed to getParameter to get the vendor string of the graphics driver. */ + UNMASKED_VENDOR_WEBGL = 0x9245, + /** Passed to getParameter to get the renderer string of the graphics driver. */ + UNMASKED_RENDERER_WEBGL = 0x9246, + + // EXT_texture_filter_anisotropic + + /** Returns the maximum available anisotropy. */ + MAX_TEXTURE_MAX_ANISOTROPY_EXT = 0x84ff, + /** Passed to texParameter to set the desired maximum anisotropy for a texture. */ + TEXTURE_MAX_ANISOTROPY_EXT = 0x84fe, + + // EXT_texture_norm16 - https://khronos.org/registry/webgl/extensions/EXT_texture_norm16/ + + R16_EXT = 0x822a, + RG16_EXT = 0x822c, + RGB16_EXT = 0x8054, + RGBA16_EXT = 0x805b, + R16_SNORM_EXT = 0x8f98, + RG16_SNORM_EXT = 0x8f99, + RGB16_SNORM_EXT = 0x8f9a, + RGBA16_SNORM_EXT = 0x8f9b, + + // WEBGL_compressed_texture_s3tc (BC1, BC2, BC3) + + /** A DXT1-compressed image in an RGB image format. */ + COMPRESSED_RGB_S3TC_DXT1_EXT = 0x83f0, + /** A DXT1-compressed image in an RGB image format with a simple on/off alpha value. */ + COMPRESSED_RGBA_S3TC_DXT1_EXT = 0x83f1, + /** A DXT3-compressed image in an RGBA image format. Compared to a 32-bit RGBA texture, it offers 4:1 compression. */ + COMPRESSED_RGBA_S3TC_DXT3_EXT = 0x83f2, + /** A DXT5-compressed image in an RGBA image format. It also provides a 4:1 compression, but differs to the DXT3 compression in how the alpha compression is done. */ + COMPRESSED_RGBA_S3TC_DXT5_EXT = 0x83f3, + + // WEBGL_compressed_texture_s3tc_srgb (BC1, BC2, BC3 - SRGB) + + COMPRESSED_SRGB_S3TC_DXT1_EXT = 0x8c4c, + COMPRESSED_SRGB_ALPHA_S3TC_DXT1_EXT = 0x8c4d, + COMPRESSED_SRGB_ALPHA_S3TC_DXT3_EXT = 0x8c4e, + COMPRESSED_SRGB_ALPHA_S3TC_DXT5_EXT = 0x8c4f, + + // WEBGL_compressed_texture_rgtc (BC4, BC5) + + COMPRESSED_RED_RGTC1_EXT = 0x8dbb, + COMPRESSED_SIGNED_RED_RGTC1_EXT = 0x8dbc, + COMPRESSED_RED_GREEN_RGTC2_EXT = 0x8dbd, + COMPRESSED_SIGNED_RED_GREEN_RGTC2_EXT = 0x8dbe, + + // WEBGL_compressed_texture_bptc (BC6, BC7) + + COMPRESSED_RGBA_BPTC_UNORM_EXT = 0x8e8c, + COMPRESSED_SRGB_ALPHA_BPTC_UNORM_EXT = 0x8e8d, + COMPRESSED_RGB_BPTC_SIGNED_FLOAT_EXT = 0x8e8e, + COMPRESSED_RGB_BPTC_UNSIGNED_FLOAT_EXT = 0x8e8f, + + // WEBGL_compressed_texture_es3 + + /** One-channel (red) unsigned format compression. */ + COMPRESSED_R11_EAC = 0x9270, + /** One-channel (red) signed format compression. */ + COMPRESSED_SIGNED_R11_EAC = 0x9271, + /** Two-channel (red and green) unsigned format compression. */ + COMPRESSED_RG11_EAC = 0x9272, + /** Two-channel (red and green) signed format compression. */ + COMPRESSED_SIGNED_RG11_EAC = 0x9273, + /** Compresses RGB8 data with no alpha channel. */ + COMPRESSED_RGB8_ETC2 = 0x9274, + /** Compresses RGBA8 data. The RGB part is encoded the same as RGB_ETC2, but the alpha part is encoded separately. */ + COMPRESSED_RGBA8_ETC2_EAC = 0x9275, + /** Compresses sRGB8 data with no alpha channel. */ + COMPRESSED_SRGB8_ETC2 = 0x9276, + /** Compresses sRGBA8 data. The sRGB part is encoded the same as SRGB_ETC2, but the alpha part is encoded separately. */ + COMPRESSED_SRGB8_ALPHA8_ETC2_EAC = 0x9277, + /** Similar to RGB8_ETC, but with ability to punch through the alpha channel, which means to make it completely opaque or transparent. */ + COMPRESSED_RGB8_PUNCHTHROUGH_ALPHA1_ETC2 = 0x9278, + /** Similar to SRGB8_ETC, but with ability to punch through the alpha channel, which means to make it completely opaque or transparent. */ + COMPRESSED_SRGB8_PUNCHTHROUGH_ALPHA1_ETC2 = 0x9279, + + // WEBGL_compressed_texture_pvrtc + + /** RGB compression in 4-bit mode. One block for each 4×4 pixels. */ + COMPRESSED_RGB_PVRTC_4BPPV1_IMG = 0x8c00, + /** RGBA compression in 4-bit mode. One block for each 4×4 pixels. */ + COMPRESSED_RGBA_PVRTC_4BPPV1_IMG = 0x8c02, + /** RGB compression in 2-bit mode. One block for each 8×4 pixels. */ + COMPRESSED_RGB_PVRTC_2BPPV1_IMG = 0x8c01, + /** RGBA compression in 2-bit mode. One block for each 8×4 pixels. */ + COMPRESSED_RGBA_PVRTC_2BPPV1_IMG = 0x8c03, + + // WEBGL_compressed_texture_etc1 + + /** Compresses 24-bit RGB data with no alpha channel. */ + COMPRESSED_RGB_ETC1_WEBGL = 0x8d64, + + // WEBGL_compressed_texture_atc + + COMPRESSED_RGB_ATC_WEBGL = 0x8c92, + COMPRESSED_RGBA_ATC_EXPLICIT_ALPHA_WEBGL = 0x8c92, + COMPRESSED_RGBA_ATC_INTERPOLATED_ALPHA_WEBGL = 0x87ee, + + // WEBGL_compressed_texture_astc + + COMPRESSED_RGBA_ASTC_4x4_KHR = 0x93b0, + COMPRESSED_RGBA_ASTC_5x4_KHR = 0x93b1, + COMPRESSED_RGBA_ASTC_5x5_KHR = 0x93b2, + COMPRESSED_RGBA_ASTC_6x5_KHR = 0x93b3, + COMPRESSED_RGBA_ASTC_6x6_KHR = 0x93b4, + COMPRESSED_RGBA_ASTC_8x5_KHR = 0x93b5, + COMPRESSED_RGBA_ASTC_8x6_KHR = 0x93b6, + COMPRESSED_RGBA_ASTC_8x8_KHR = 0x93b7, + COMPRESSED_RGBA_ASTC_10x5_KHR = 0x93b8, + COMPRESSED_RGBA_ASTC_10x6_KHR = 0x93b9, + COMPRESSED_RGBA_ASTC_10x8_KHR = 0x93ba, + COMPRESSED_RGBA_ASTC_10x10_KHR = 0x93bb, + COMPRESSED_RGBA_ASTC_12x10_KHR = 0x93bc, + COMPRESSED_RGBA_ASTC_12x12_KHR = 0x93bd, + COMPRESSED_SRGB8_ALPHA8_ASTC_4x4_KHR = 0x93d0, + COMPRESSED_SRGB8_ALPHA8_ASTC_5x4_KHR = 0x93d1, + COMPRESSED_SRGB8_ALPHA8_ASTC_5x5_KHR = 0x93d2, + COMPRESSED_SRGB8_ALPHA8_ASTC_6x5_KHR = 0x93d3, + COMPRESSED_SRGB8_ALPHA8_ASTC_6x6_KHR = 0x93d4, + COMPRESSED_SRGB8_ALPHA8_ASTC_8x5_KHR = 0x93d5, + COMPRESSED_SRGB8_ALPHA8_ASTC_8x6_KHR = 0x93d6, + COMPRESSED_SRGB8_ALPHA8_ASTC_8x8_KHR = 0x93d7, + COMPRESSED_SRGB8_ALPHA8_ASTC_10x5_KHR = 0x93d8, + COMPRESSED_SRGB8_ALPHA8_ASTC_10x6_KHR = 0x93d9, + COMPRESSED_SRGB8_ALPHA8_ASTC_10x8_KHR = 0x93da, + COMPRESSED_SRGB8_ALPHA8_ASTC_10x10_KHR = 0x93db, + COMPRESSED_SRGB8_ALPHA8_ASTC_12x10_KHR = 0x93dc, + COMPRESSED_SRGB8_ALPHA8_ASTC_12x12_KHR = 0x93dd, + + // EXT_disjoint_timer_query + + /** The number of bits used to hold the query result for the given target. */ + QUERY_COUNTER_BITS_EXT = 0x8864, + /** The currently active query. */ + CURRENT_QUERY_EXT = 0x8865, + /** The query result. */ + QUERY_RESULT_EXT = 0x8866, + /** A Boolean indicating whether or not a query result is available. */ + QUERY_RESULT_AVAILABLE_EXT = 0x8867, + /** Elapsed time (in nanoseconds). */ + TIME_ELAPSED_EXT = 0x88bf, + /** The current time. */ + TIMESTAMP_EXT = 0x8e28, + /** A Boolean indicating whether or not the GPU performed any disjoint operation (lost context) */ + GPU_DISJOINT_EXT = 0x8fbb, + + // KHR_parallel_shader_compile https://registry.khronos.org/webgl/extensions/KHR_parallel_shader_compile + + /** a non-blocking poll operation, so that compile/link status availability can be queried without potentially incurring stalls */ + COMPLETION_STATUS_KHR = 0x91b1, + + // EXT_depth_clamp https://registry.khronos.org/webgl/extensions/EXT_depth_clamp/ + + /** Disables depth clipping */ + DEPTH_CLAMP_EXT = 0x864f, + + // WEBGL_provoking_vertex https://registry.khronos.org/webgl/extensions/WEBGL_provoking_vertex/ + + /** Values of first vertex in primitive are used for flat shading */ + FIRST_VERTEX_CONVENTION_WEBGL = 0x8e4d, + /** Values of first vertex in primitive are used for flat shading */ + LAST_VERTEX_CONVENTION_WEBGL = 0x8e4e, // default + /** Controls which vertex in primitive is used for flat shading */ + PROVOKING_VERTEX_WEBL = 0x8e4f, + + // WEBGL_polygon_mode https://registry.khronos.org/webgl/extensions/WEBGL_polygon_mode/ + + POLYGON_MODE_WEBGL = 0x0b40, + POLYGON_OFFSET_LINE_WEBGL = 0x2a02, + LINE_WEBGL = 0x1b01, + FILL_WEBGL = 0x1b02, + + // WEBGL_clip_cull_distance https://registry.khronos.org/webgl/extensions/WEBGL_clip_cull_distance/ + + /** Max clip distances */ + MAX_CLIP_DISTANCES_WEBGL = 0x0d32, + /** Max cull distances */ + MAX_CULL_DISTANCES_WEBGL = 0x82f9, + /** Max clip and cull distances */ + MAX_COMBINED_CLIP_AND_CULL_DISTANCES_WEBGL = 0x82fa, + + /** Enable gl_ClipDistance[0] and gl_CullDistance[0] */ + CLIP_DISTANCE0_WEBGL = 0x3000, + /** Enable gl_ClipDistance[1] and gl_CullDistance[1] */ + CLIP_DISTANCE1_WEBGL = 0x3001, + /** Enable gl_ClipDistance[2] and gl_CullDistance[2] */ + CLIP_DISTANCE2_WEBGL = 0x3002, + /** Enable gl_ClipDistance[3] and gl_CullDistance[3] */ + CLIP_DISTANCE3_WEBGL = 0x3003, + /** Enable gl_ClipDistance[4] and gl_CullDistance[4] */ + CLIP_DISTANCE4_WEBGL = 0x3004, + /** Enable gl_ClipDistance[5] and gl_CullDistance[5] */ + CLIP_DISTANCE5_WEBGL = 0x3005, + /** Enable gl_ClipDistance[6] and gl_CullDistance[6] */ + CLIP_DISTANCE6_WEBGL = 0x3006, + /** Enable gl_ClipDistance[7] and gl_CullDistance[7] */ + CLIP_DISTANCE7_WEBGL = 0x3007, + + /** EXT_polygon_offset_clamp https://registry.khronos.org/webgl/extensions/EXT_polygon_offset_clamp/ */ + POLYGON_OFFSET_CLAMP_EXT = 0x8e1b, + + /** EXT_clip_control https://registry.khronos.org/webgl/extensions/EXT_clip_control/ */ + LOWER_LEFT_EXT = 0x8ca1, + UPPER_LEFT_EXT = 0x8ca2, + + NEGATIVE_ONE_TO_ONE_EXT = 0x935e, + ZERO_TO_ONE_EXT = 0x935f, + + CLIP_ORIGIN_EXT = 0x935c, + CLIP_DEPTH_MODE_EXT = 0x935d, + + /** WEBGL_blend_func_extended https://registry.khronos.org/webgl/extensions/WEBGL_blend_func_extended/ */ + SRC1_COLOR_WEBGL = 0x88f9, + SRC1_ALPHA_WEBGL = 0x8589, + ONE_MINUS_SRC1_COLOR_WEBGL = 0x88fa, + ONE_MINUS_SRC1_ALPHA_WEBGL = 0x88fb, + MAX_DUAL_SOURCE_DRAW_BUFFERS_WEBGL = 0x88fc, + + /** EXT_texture_mirror_clamp_to_edge https://registry.khronos.org/webgl/extensions/EXT_texture_mirror_clamp_to_edge/ */ + MIRROR_CLAMP_TO_EDGE_EXT = 0x8743, +} + +export {GLEnum as GL} diff --git a/panel/models/plotly.ts b/panel/models/plotly.ts index ed174f6272..2c2099e079 100644 --- a/panel/models/plotly.ts +++ b/panel/models/plotly.ts @@ -2,16 +2,16 @@ import {ModelEvent} from "@bokehjs/core/bokeh_events" import type {StyleSheetLike} from "@bokehjs/core/dom" import {div} from "@bokehjs/core/dom" import type * as p from "@bokehjs/core/properties" -import {isPlainObject, isArray} from "@bokehjs/core/util/types" +import {isPlainObject} from "@bokehjs/core/util/types" import {clone} from "@bokehjs/core/util/object" import {is_equal} from "@bokehjs/core/util/eq" import type {Attrs} from "@bokehjs/core/types" import {ColumnDataSource} from "@bokehjs/models/sources/column_data_source" import {debounce} from "debounce" -import {deepCopy, get, reshape, throttle} from "./util" import {HTMLBox, HTMLBoxView, set_size} from "./layout" +import {convertUndefined, deepCopy, get, reshape, throttle} from "./util" import plotly_css from "styles/models/plotly.css" @@ -44,23 +44,6 @@ interface PlotlyHTMLElement extends HTMLDivElement { on(event: "plotly_unhover", callback: () => void): void } -function convertUndefined(obj: any): any { - if (isArray(obj)) { - return obj.map(convertUndefined) - } else if (isPlainObject(obj)) { - Object - .entries(obj) - .forEach(([key, value]) => { - if (isPlainObject(value) || isArray(value)) { - convertUndefined(value) - } else if (value === undefined) { - obj[key] = null - } - }) - } - return obj -} - const filterEventData = (gd: any, eventData: any, event: string) => { // Ported from dash-core-components/src/components/Graph.react.js const filteredEventData: {[k: string]: any} = Array.isArray(eventData)? []: {} diff --git a/panel/models/react_component.ts b/panel/models/react_component.ts new file mode 100644 index 0000000000..5ba1661838 --- /dev/null +++ b/panel/models/react_component.ts @@ -0,0 +1,272 @@ +import type * as p from "@bokehjs/core/properties" +import type {Transform} from "sucrase" + +import { + ReactiveESM, ReactiveESMView, model_getter, model_setter, +} from "./reactive_esm" + +export class ReactComponentView extends ReactiveESMView { + declare model: ReactComponent + declare style_cache: HTMLHeadElement + model_getter = model_getter + model_setter = model_setter + + override render_esm(): void { + if (this.model.usesMui) { + this.style_cache = document.createElement("head") + this.shadow_el.insertBefore(this.style_cache, this.container) + } + super.render_esm() + } + + override after_rendered(): void { + const handlers = (this._lifecycle_handlers.get("after_render") || []) + for (const cb of handlers) { + cb() + } + this._rendered = true + } + + protected override _render_code(): string { + let render_code = ` +if (rendered && view.model.usesReact) { + view._changing = true + const root = createRoot(view.container) + try { + root.render(rendered) + } catch(e) { + view.render_error(e) + } + view._changing = false + view.after_rendered() +}` + let import_code = ` +import * as React from "react" +import { createRoot } from "react-dom/client"` + if (this.model.usesMui) { + import_code = ` +${import_code} +import createCache from "@emotion/cache" +import { CacheProvider } from "@emotion/react"` + render_code = ` +if (rendered) { + const cache = createCache({ + key: 'css', + prepend: true, + container: view.style_cache, + }) + rendered = React.createElement(CacheProvider, {value: cache}, rendered) +} +${render_code}` + } + return ` +${import_code} + +const view = Bokeh.index.find_one_by_id('${this.model.id}') + +class Child extends React.Component { + + get view() { + const model = this.props.parent.model.data[this.props.name] + const models = Array.isArray(model) ? model : [model] + return this.props.parent.get_child_view(models[this.props.index]) + } + + get element() { + return this.view.el + } + + componentDidMount() { + this.view.render() + this.view.after_render() + this.props.parent.on_child_render(this.props.name, (new_views) => { + this.forceUpdate() + const view = this.view + if (new_views.includes(view)) { + view.render() + view.after_render() + } + }) + } + + render() { + return React.createElement('div', {className: "child-wrapper", ref: (ref) => ref && ref.appendChild(this.element)}) + } +} + +function react_getter(target, name) { + if (name == "useState") { + return (prop) => { + const data_model = target.model.data + if (Reflect.has(data_model, prop)) { + const [value, setValue] = React.useState(data_model.attributes[prop]); + react_proxy.on(prop, () => setValue(data_model.attributes[prop])) + React.useEffect(() => data_model.setv({[prop]: value}), [value]) + return [value, setValue] + } + return undefined + } + } else if (name === "get_child") { + return (child) => { + const data_model = target.model.data + const value = data_model.attributes[child] + if (Array.isArray(value)) { + const children = [] + for (let i = 0; i { + const value = data_model.attributes[child] + if (new_views.length && children_state.length !== value.length) { + const children = [] + for (let i = 0; i + + export type Props = ReactiveESM.Props & { + react_version: p.Property + } +} + +export interface ReactComponent extends ReactComponent.Attrs {} + +export class ReactComponent extends ReactiveESM { + declare properties: ReactComponent.Props + override sucrase_transforms: Transform[] = ["typescript", "jsx"] + + constructor(attrs?: Partial) { + super(attrs) + } + + get usesMui(): boolean { + if (this.importmap?.imports) { + return Object.keys(this.importmap?.imports).some(k => k.startsWith("@mui")) + } + return false + } + + get usesReact(): boolean { + return this.compiled !== null && this.compiled.includes("React") + } + + protected override _declare_importmap(): void { + const react_version = this.react_version + const imports = this.importmap?.imports + const scopes = this.importmap?.scopes + const pkg_suffix = this.dev ? "?dev": "" + const path_suffix = this.dev ? "&dev": "" + const importMap = { + imports: { + react: `https://esm.sh/react@${react_version}${pkg_suffix}`, + "react/": `https://esm.sh/react@${react_version}${path_suffix}/`, + "react-dom/": `https://esm.sh/react-dom@${react_version}&deps=react@${react_version},react-dom@${react_version}${path_suffix}/`, + ...imports, + }, + scopes: scopes || {}, + } + if (this.usesMui) { + importMap.imports = { + ...importMap.imports, + "@emotion/cache": "https://esm.sh/@emotion/cache", + "@emotion/react": `https://esm.sh/@emotion/react?external=react${path_suffix}`, + } + } + // @ts-ignore + importShim.addImportMap(importMap) + } + + override compile(): string | null { + const compiled = super.compile() + if (compiled === null || !compiled.includes("React")) { + return compiled + } + return ` +import * as React from "react" + +${compiled}` + } + + static override __module__ = "panel.models.esm" + + static { + this.prototype.default_view = ReactComponentView + + this.define(({String}) => ({ + react_version: [ String, "18.3.1" ], + })) + } +} diff --git a/panel/models/reactive_esm.ts b/panel/models/reactive_esm.ts new file mode 100644 index 0000000000..9bcdf0d67e --- /dev/null +++ b/panel/models/reactive_esm.ts @@ -0,0 +1,591 @@ +import {transform} from "sucrase" +import type {Transform} from "sucrase" + +import {ModelEvent, server_event} from "@bokehjs/core/bokeh_events" +import {div} from "@bokehjs/core/dom" +import type {StyleSheetLike} from "@bokehjs/core/dom" +import type * as p from "@bokehjs/core/properties" +import type {Attrs} from "@bokehjs/core/types" +import type {LayoutDOM} from "@bokehjs/models/layouts/layout_dom" +import {isArray} from "@bokehjs/core/util/types" +import type {UIElement, UIElementView} from "@bokehjs/models/ui/ui_element" + +import {serializeEvent} from "./event-to-object" +import {DOMEvent} from "./html" +import {HTMLBox, HTMLBoxView, set_size} from "./layout" +import {convertUndefined, formatError} from "./util" + +import error_css from "styles/models/esm.css" + +@server_event("esm_event") +export class ESMEvent extends ModelEvent { + constructor(readonly model: ReactiveESM, readonly data: any) { + super() + this.data = data + this.origin = model + } + + protected override get event_values(): Attrs { + return {model: this.origin, data: this.data} + } + + static override from_values(values: object) { + const {model, data} = values as {model: ReactiveESM, data: any} + return new ESMEvent(model, data) + } +} + +export function model_getter(target: ReactiveESMView, name: string) { + const model = target.model + if (name === "get_child") { + return (child: string) => { + if (!target.accessed_children.includes(child)) { + target.accessed_children.push(child) + } + const child_model: UIElement | UIElement[] = model.data[child] + if (isArray(child_model)) { + const children = [] + for (const subchild of child_model) { + children.push(target.get_child_view(subchild)?.el) + } + return children + } else if (model != null) { + return target.get_child_view(child_model)?.el + } + return null + } + } else if (name === "send_event") { + return (name: string, event: Event) => { + const serialized = convertUndefined(serializeEvent(event)) + model.trigger_event(new DOMEvent(name, serialized)) + } + } else if (name === "off") { + return (prop: string | string[], callback: any) => { + const props = isArray(prop) ? prop : [prop] + for (let p of props) { + if (p.startsWith("change:")) { + p = p.slice("change:".length) + } + if (p in model.attributes || p in model.data.attributes) { + model.unwatch(target, p, callback) + continue + } else if (p === "msg:custom") { + target.remove_on_event(callback) + continue + } + if (p.startsWith("lifecycle:")) { + p = p.slice("lifecycle:".length) + } + if (target._lifecycle_handlers.has(p)) { + const handlers = target._lifecycle_handlers.get(p) + if (handlers && handlers.includes(callback)) { + target._lifecycle_handlers.set(p, handlers.filter(v => v !== callback)) + } + continue + } + console.warn(`Could not unregister callback for event type '${p}'`) + } + } + } else if (name === "on") { + return (prop: string | string[], callback: any) => { + const props = isArray(prop) ? prop : [prop] + for (let p of props) { + if (p.startsWith("change:")) { + p = p.slice("change:".length) + } + if (p in model.attributes || p in model.data.attributes) { + model.watch(target, p, callback) + continue + } else if (p === "msg:custom") { + target.on_event(callback) + continue + } + if (p.startsWith("lifecycle:")) { + p = p.slice("lifecycle:".length) + } + if (target._lifecycle_handlers.has(p)) { + (target._lifecycle_handlers.get(p) || []).push(callback) + continue + } + console.warn(`Could not register callback for event type '${p}'`) + } + } + } else if (Reflect.has(model.data, name)) { + if (name in model.data.attributes && !target.accessed_properties.includes(name)) { + target.accessed_properties.push(name) + } + return Reflect.get(model.data, name) + } else if (Reflect.has(model, name)) { + return Reflect.get(model, name) + } + return undefined +} + +export function model_setter(target: ReactiveESMView, name: string, value: any): boolean { + const model = target.model + if (Reflect.has(model.data, name)) { + return Reflect.set(model.data, name, value) + } else if (Reflect.has(model, name)) { + return Reflect.set(model, name, value) + } + return false +} + +function init_model_getter(target: ReactiveESM, name: string) { + if (Reflect.has(target.data, name)) { + return Reflect.get(target.data, name) + } else if (Reflect.has(target, name)) { + return Reflect.get(target, name) + } +} + +export class ReactiveESMView extends HTMLBoxView { + declare model: ReactiveESM + container: HTMLDivElement + accessed_properties: string[] = [] + accessed_children: string[] = [] + compiled_module: any = null + model_proxy: any + _changing: boolean = false + _child_callbacks: Map void)[]> + _event_handlers: ((event: ESMEvent) => void)[] = [] + _lifecycle_handlers: Map void)[]> = new Map([ + ["after_render", []], + ["after_resize", []], + ["remove", []], + ]) + _rendered: boolean = false + + override initialize(): void { + super.initialize() + this.model_proxy = new Proxy(this, { + get: model_getter, + set: model_setter, + }) + } + + override async lazy_initialize(): Promise { + await super.lazy_initialize() + this.compiled_module = await this.model.compiled_module + } + + override stylesheets(): StyleSheetLike[] { + const stylesheets = super.stylesheets() + if (this.model.dev) { + stylesheets.push(error_css) + } + return stylesheets + } + + override connect_signals(): void { + super.connect_signals() + const {esm, importmap} = this.model.properties + this.on_change([esm, importmap], async () => { + this.compiled_module = await this.model.compiled_module + this.invalidate_render() + }) + const child_props = this.model.children.map((child: string) => this.model.data.properties[child]) + this.on_change(child_props, () => { + this.update_children() + }) + this.model.on_event(ESMEvent, (event: ESMEvent) => { + for (const cb of this._event_handlers) { + cb(event.data) + } + }) + } + + override disconnect_signals(): void { + super.disconnect_signals() + this._child_callbacks = new Map() + this.model.disconnect_watchers(this) + } + + on_event(callback: (event: ESMEvent) => void): void { + this._event_handlers.push(callback) + } + + remove_on_event(callback: (event: ESMEvent) => void): boolean { + if (this._event_handlers.includes(callback)) { + this._event_handlers = this._event_handlers.filter((item) => item !== callback) + return true + } + return false + } + + get_child_view(model: UIElement): UIElementView | undefined { + return this._child_views.get(model) + } + + get render_fn(): ((props: any) => any) | null { + if (this.compiled_module === null) { + return null + } else if (this.compiled_module.default) { + return this.compiled_module.default.render + } else { + return this.compiled_module.render + } + } + + override get child_models(): LayoutDOM[] { + const children = [] + for (const child of this.model.children) { + const model = this.model.data[child] + if (isArray(model)) { + for (const subchild of model) { + children.push(subchild) + } + } else if (model != null) { + children.push(model) + } + } + return children + } + + render_error(error: SyntaxError): void { + const error_div = div({class: "error"}) + error_div.innerHTML = formatError(error, this.model.esm) + this.container.appendChild(error_div) + } + + override render(): void { + this.empty() + this._update_stylesheets() + this._update_css_classes() + this._apply_styles() + this._apply_visible() + + this._child_callbacks = new Map() + + this._rendered = false + set_size(this.el, this.model) + this.container = div() + set_size(this.container, this.model, false) + this.shadow_el.append(this.container) + if (this.model.compile_error) { + this.render_error(this.model.compile_error) + } else { + this.render_esm() + } + } + + protected _render_code(): string { + return ` +const view = Bokeh.index.find_one_by_id('${this.model.id}') + +const output = view.render_fn({ + view: view, model: view.model_proxy, data: view.model.data, el: view.container +}) + +Promise.resolve(output).then((out) => { + if (out instanceof Element) { + view.container.replaceChildren(out) + } + view.after_rendered() +})` + } + + after_rendered(): void { + const handlers = (this._lifecycle_handlers.get("after_render") || []) + for (const cb of handlers) { + cb() + } + this.render_children() + this.model_proxy.on(this.accessed_children, () => this.render_esm()) + this._rendered = true + } + + render_esm(): void { + if (this.model.compiled === null) { + return + } + this.accessed_properties = [] + for (const lf of this._lifecycle_handlers.keys()) { + (this._lifecycle_handlers.get(lf) || []).splice(0) + } + this.model.disconnect_watchers(this) + const code = this._render_code() + const render_url = URL.createObjectURL( + new Blob([code], {type: "text/javascript"}), + ) + // @ts-ignore + importShim(render_url) + } + + render_children() { + for (const child of this.model.children) { + const child_model = this.model.data[child] + const children = isArray(child_model) ? child_model : [child_model] + for (const subchild of children) { + const view = this._child_views.get(subchild) + if (!view) { + continue + } + const parent = view.el.parentNode + if (parent) { + view.render() + view.after_render() + } + } + } + } + + override remove(): void { + super.remove() + for (const cb of (this._lifecycle_handlers.get("remove") || [])) { + cb() + } + } + + override after_resize(): void { + super.after_resize() + if (this._rendered && !this._changing) { + for (const cb of (this._lifecycle_handlers.get("after_resize") || [])) { + cb() + } + } + } + + protected _lookup_child(child_view: UIElementView): string | null { + for (const child of this.model.children) { + let models = this.model.data[child] + models = isArray(models) ? models : [models] + for (const model of models) { + if (model === child_view.model) { + return child + } + } + } + return null + } + + override async update_children(): Promise { + const created_children = new Set(await this.build_child_views()) + + for (const child_view of this.child_views) { + child_view.el.remove() + } + + const new_views = new Map() + for (const child_view of this.child_views) { + if (!created_children.has(child_view)) { + continue + } + const child = this._lookup_child(child_view) + if (!child) { + continue + } else if (new_views.has(child)) { + new_views.get(child).push(child_view) + } else { + new_views.set(child, [child_view]) + } + } + + for (const child of this.model.children) { + const callbacks = this._child_callbacks.get(child) || [] + const new_children = new_views.get(child) || [] + for (const callback of callbacks) { + callback(new_children) + } + } + + this._update_children() + this.invalidate_layout() + } + + on_child_render(child: string, callback: (new_views: UIElementView[]) => void): void { + if (!this._child_callbacks.has(child)) { + this._child_callbacks.set(child, []) + } + const callbacks = this._child_callbacks.get(child) || [] + callbacks.push(callback) + } + + remove_on_child_render(child: string): void { + this._child_callbacks.delete(child) + } +} + +export namespace ReactiveESM { + export type Attrs = p.AttrsOf + + export type Props = HTMLBox.Props & { + children: p.Property + data: p.Property + dev: p.Property + esm: p.Property + importmap: p.Property + } +} + +export interface ReactiveESM extends ReactiveESM.Attrs {} + +export class ReactiveESM extends HTMLBox { + declare properties: ReactiveESM.Props + compiled: string | null = null + compiled_module: Promise | null = null + compile_error: Error | null = null + model_proxy: any + sucrase_transforms: Transform[] = ["typescript"] + _destroyer: any | null = null + _esm_watchers: any = {} + + constructor(attrs?: Partial) { + super(attrs) + } + + override initialize(): void { + super.initialize() + this.model_proxy = new Proxy(this, {get: init_model_getter}) + this.recompile() + } + + override connect_signals(): void { + super.connect_signals() + this.connect(this.properties.esm.change, () => this.recompile()) + this.connect(this.properties.importmap.change, () => this.recompile()) + } + + watch(view: ReactiveESMView | null, prop: string, cb: any): void { + if (prop in this._esm_watchers) { + this._esm_watchers[prop].push([view, cb]) + } else { + this._esm_watchers[prop] = [[view, cb]] + } + if (prop in this.data.properties) { + this.data.property(prop).change.connect(cb) + } else if (prop in this.properties) { + this.property(prop).change.connect(cb) + } + } + + unwatch(view: ReactiveESMView | null, prop: string, cb: any): boolean { + if (!(prop in this._esm_watchers)) { + return false + } + const remaining = [] + for (const [wview, wcb] of this._esm_watchers[prop]) { + if (wview !== view || wcb !== cb) { + remaining.push([wview, cb]) + } + } + if (remaining.length > 0) { + this._esm_watchers[prop] = remaining + } else { + delete this._esm_watchers[prop] + } + if (prop in this.data.properties) { + return this.data.property(prop).change.disconnect(cb) + } else if (prop in this.properties) { + return this.property(prop).change.disconnect(cb) + } + return false + } + + disconnect_watchers(view: ReactiveESMView): void { + for (const p in this._esm_watchers) { + const prop = this.data.properties[p] + const remaining = [] + for (const [wview, cb] of this._esm_watchers[p]) { + if (wview === view) { + prop.change.disconnect(cb) + } else { + remaining.push([wview, cb]) + } + } + if (remaining.length > 0) { + this._esm_watchers[p] = remaining + } else { + delete this._esm_watchers[p] + } + } + } + + protected _declare_importmap(): void { + if (this.importmap) { + const importMap = {...this.importmap} + // @ts-ignore + importShim.addImportMap(importMap) + } + } + + protected _run_initializer(initialize: (props: any) => any): void { + const props = {model: this.model_proxy} + this._destroyer = initialize(props) + } + + override destroy(): void { + super.destroy() + if (this._destroyer) { + this._destroyer(this.model_proxy) + } + } + + compile(): string | null { + let compiled + try { + compiled = transform( + this.esm, { + transforms: this.sucrase_transforms, + filePath: "render.tsx", + }, + ).code + } catch (e) { + if (e instanceof SyntaxError && this.dev) { + this.compile_error = e + return null + } else { + throw e + } + } + return compiled + } + + async recompile(): Promise { + this.compile_error = null + const compiled = this.compile() + if (compiled === null) { + this.compiled_module = null + return + } + this.compiled = compiled + this._declare_importmap() + const url = URL.createObjectURL( + new Blob([this.compiled], {type: "text/javascript"}), + ) + try { + // @ts-ignore + this.compiled_module = importShim(url) + const mod = await this.compiled_module + let initialize + if (mod.initialize) { + initialize = mod.initialize + } else if (mod.default && mod.default.initialize) { + initialize = mod.default.initialize + } + if (initialize) { + this._run_initializer(initialize) + } + } catch (e: any) { + this.compiled_module = null + if (this.dev) { + this.compile_error = e + } else { + throw e + } + } + } + + static override __module__ = "panel.models.esm" + + static { + this.prototype.default_view = ReactiveESMView + this.define(({Any, Array, Bool, String}) => ({ + children: [ Array(String), [] ], + data: [ Any ], + dev: [ Bool, false ], + esm: [ String, "" ], + importmap: [ Any, {} ], + })) + } +} diff --git a/panel/models/reactive_html.py b/panel/models/reactive_html.py index 30972a1695..9f2a536e15 100644 --- a/panel/models/reactive_html.py +++ b/panel/models/reactive_html.py @@ -208,7 +208,6 @@ def find_attrs(html): return p.attrs - class DOMEvent(ModelEvent): event_name = 'dom_event' diff --git a/panel/models/reactive_html.ts b/panel/models/reactive_html.ts index 1c7977ddaa..25d0227023 100644 --- a/panel/models/reactive_html.ts +++ b/panel/models/reactive_html.ts @@ -16,6 +16,7 @@ import {dict_to_records} from "./data" import {serializeEvent} from "./event-to-object" import {DOMEvent, html_decode} from "./html" import {HTMLBox, HTMLBoxView} from "./layout" +import {convertUndefined} from "./util" function serialize_attrs(attrs: Attrs): Attrs { const serialized: Attrs = {} @@ -269,13 +270,8 @@ export class ReactiveHTMLView extends HTMLBoxView { } private _send_event(elname: string, attr: string, event: any) { - const serialized = serializeEvent(event) + const serialized = convertUndefined(serializeEvent(event)) serialized.type = attr - for (const key in serialized) { - if (serialized[key] === undefined) { - delete serialized[key] - } - } this.model.trigger_event(new DOMEvent(elname, serialized)) } diff --git a/panel/models/util.ts b/panel/models/util.ts index 86d256043d..2697a747b2 100644 --- a/panel/models/util.ts +++ b/panel/models/util.ts @@ -1,4 +1,5 @@ -import {concat} from "@bokehjs/core/util/array" +import {concat, uniq} from "@bokehjs/core/util/array" +import {isPlainObject, isArray} from "@bokehjs/core/util/types" export const get = (obj: any, path: string, defaultValue: any = undefined) => { const travel = (regexp: RegExp) => @@ -76,3 +77,73 @@ export function reshape(arr: any[], dim: number[]) { } return _nest(0) } + +export async function loadScript(type: string, src: string) { + const script = document.createElement("script") + script.type = type + script.src = src + script.defer = true + document.head.appendChild(script) + return new Promise((resolve, reject) => { + script.onload = () => { + resolve() + } + script.onerror = () => { + reject() + } + }) +} + +export function convertUndefined(obj: any): any { + if (isArray(obj)) { + return obj.map(convertUndefined) + } else if (isPlainObject(obj)) { + Object + .entries(obj) + .forEach(([key, value]) => { + if (isPlainObject(value) || isArray(value)) { + convertUndefined(value) + } else if (value === undefined) { + obj[key] = null + } + }) + } + return obj +} + +export function formatError(error: SyntaxError, code: string): string { + const regex = /\((\d+):(\d+)\)/ + let msg = `${error}` + const match = msg.match(regex) + if (!match) { + return msg + } + const line_num = parseInt(match[1]) + const col = parseInt(match[2]) + const start = Math.max(0, line_num-5) + const col_index = line_num-start + const lines = code.replace(">", "<").replace("<", ">").split(/\r?\n/).slice(start, line_num+5) + msg += "

" + for (let i = 0; i < col_index; i++) { + const cls = (i == (col_index-1)) ? " class=\"highlight\"" : "" + msg += `${lines[i]}` + } + const indent = " ".repeat(col-1) + msg += `
${indent}^
` + for (let i = col_index; i < lines.length; i++) { + msg += `
${lines[i]}
` + } + return msg +} + +export function find_attributes(text: string, obj: string, ignored: string[]) { + const regex = RegExp(`\\b${obj}\\.([a-zA-Z_][a-zA-Z0-9_]*)\\b`, "g") + const matches = [] + let match, attr + + while ((match = regex.exec(text)) !== null && (attr = match[0].slice(obj.length+1)) !== null && !ignored.includes(attr)) { + matches.push(attr) + } + + return uniq(matches) +} diff --git a/panel/package-lock.json b/panel/package-lock.json index 9a0de43b10..300770e300 100644 --- a/panel/package-lock.json +++ b/panel/package-lock.json @@ -10,7 +10,6 @@ "license": "BSD-3-Clause", "dependencies": { "@bokeh/bokehjs": "3.5.0-dev.8", - "@luma.gl/constants": "^8.0.3", "@types/debounce": "^1.2.0", "@types/gl-matrix": "^2.4.5", "ace-code": "^1.24.1", @@ -18,7 +17,8 @@ "gl-matrix": "^3.1.0", "htm": "^3.1.1", "json-formatter-js": "^2.2.1", - "preact": "^10.13.0" + "preact": "^10.22.0", + "sucrase": "^3.30.0" }, "devDependencies": { "@stylistic/eslint-plugin": "^1.6.3", @@ -234,10 +234,89 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, - "node_modules/@luma.gl/constants": { - "version": "8.5.21", - "resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-8.5.21.tgz", - "integrity": "sha512-aJxayGxTT+IRd1vfpcgD/cKSCiVJjBNiuiChS96VulrmCvkzUOLvYXr42y5qKB4RyR7vOIda5uQprNzoHrhQAA==" + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", @@ -274,6 +353,15 @@ "node": ">= 8" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@stylistic/eslint-plugin": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-1.8.1.tgz", @@ -816,7 +904,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "engines": { "node": ">=8" } @@ -825,7 +912,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -836,6 +922,11 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -854,14 +945,12 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -917,7 +1006,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -928,8 +1016,7 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/commander": { "version": "9.2.0", @@ -949,7 +1036,6 @@ "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -1019,6 +1105,16 @@ "node": ">=6.0.0" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1339,6 +1435,21 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, + "node_modules/foreground-child": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.0.tgz", + "integrity": "sha512-CrWQNaEl1/6WeZoarcM9LHupTo3RpZO2Pdk1vktwzPiQTsJnAKJmm3TACKeG5UZbWDfaH2AbvYxzP96y0MT7fA==", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -1528,6 +1639,14 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -1561,8 +1680,24 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "node_modules/jackspeak": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.0.tgz", + "integrity": "sha512-JVYhQnN59LVPFCEcVa2C3CrEKYacvjRfqIQl+h8oi91aLYQVWRYbxjPcv1bUiUy/kLmQaANrYfNMCO3kuEDHfw==", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } }, "node_modules/jquery": { "version": "3.7.1", @@ -1634,6 +1769,11 @@ "node": ">= 0.8.0" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -1655,6 +1795,14 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lru-cache": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz", + "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==", + "engines": { + "node": "14 || >=16.14" + } + }, "node_modules/mathjax-full": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/mathjax-full/-/mathjax-full-3.2.2.tgz", @@ -1714,7 +1862,6 @@ "version": "9.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", - "dev": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -1725,6 +1872,14 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mj-context-menu": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/mj-context-menu/-/mj-context-menu-0.6.1.tgz", @@ -1736,6 +1891,16 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -1747,6 +1912,14 @@ "resolved": "https://registry.npmjs.org/nouislider/-/nouislider-15.8.0.tgz", "integrity": "sha512-E7Rvt2Uc5IIl7Hv7+mO8XH8uFyqe3ofxi5j2QMi2B7w8JHVLAEzTpgDBk0oYZBaplsk60QOso3qrq8qk3JPHEQ==" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1837,11 +2010,25 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "engines": { "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -1863,6 +2050,14 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "engines": { + "node": ">= 6" + } + }, "node_modules/preact": { "version": "10.22.0", "resolved": "https://registry.npmjs.org/preact/-/preact-10.22.0.tgz", @@ -2011,7 +2206,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -2023,11 +2217,21 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "engines": { "node": ">=8" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -2055,11 +2259,82 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==" }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -2079,6 +2354,56 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz", + "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -2097,6 +2422,25 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/timezone": { "version": "1.0.23", "resolved": "https://registry.npmjs.org/timezone/-/timezone-1.0.23.tgz", @@ -2126,6 +2470,11 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" + }, "node_modules/tslib": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", @@ -2187,7 +2536,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -2217,6 +2565,93 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/panel/package.json b/panel/package.json index 24eae0e23c..d244fecdbb 100644 --- a/panel/package.json +++ b/panel/package.json @@ -9,7 +9,6 @@ }, "dependencies": { "@bokeh/bokehjs": "3.5.0-dev.8", - "@luma.gl/constants": "^8.0.3", "@types/debounce": "^1.2.0", "@types/gl-matrix": "^2.4.5", "ace-code": "^1.24.1", @@ -17,7 +16,8 @@ "gl-matrix": "^3.1.0", "htm": "^3.1.1", "json-formatter-js": "^2.2.1", - "preact": "^10.13.0" + "preact": "^10.22.0", + "sucrase": "^3.30.0" }, "devDependencies": { "@stylistic/eslint-plugin": "^1.6.3", diff --git a/panel/pane/__init__.py b/panel/pane/__init__.py index 9b86430fea..5ca76b23ce 100644 --- a/panel/pane/__init__.py +++ b/panel/pane/__init__.py @@ -30,7 +30,7 @@ https://panel.holoviz.org/getting_started/index.html """ from .alert import Alert # noqa -from .base import PaneBase, panel # noqa +from .base import Pane, PaneBase, panel # noqa from .deckgl import DeckGL # noqa from .echarts import ECharts # noqa from .equation import LaTeX # noqa @@ -78,6 +78,7 @@ "LaTeX", "Markdown", "Matplotlib", + "Pane", "PaneBase", "ParamFunction", "ParamMethod", diff --git a/panel/pane/base.py b/panel/pane/base.py index a50bd542ec..6255918402 100644 --- a/panel/pane/base.py +++ b/panel/pane/base.py @@ -101,17 +101,22 @@ def __init__(self, *args, layout=None, **kwargs): T = TypeVar('T', bound='PaneBase') -class PaneBase(Reactive): - """ - PaneBase is the abstract baseclass for all atomic displayable units - in the Panel library. We call any child class of `PaneBase` a `Pane`. - - Panes defines an extensible interface for wrapping arbitrary - objects and transforming them into Bokeh models. - Panes are reactive in the sense that when the object they are - wrapping is replaced or modified the `bokeh.model.Model` that - is rendered should reflect these changes. +class PaneBase(Layoutable): + """ + PaneBase represents an abstract baseclass which can be used as a + mix-in class to define a component that mirrors the Pane API. + This means that this component will participate in the automatic + resolution of the appropriate pane type when Panel is asked to + render an object of unknown type. + + The resolution of the appropriate pane type can be implemented + using the ``applies`` method and the ``priority`` class attribute. + The applies method should either return a boolean value indicating + whether the pane can render the supplied object. If it can the + priority determines which of the panes that apply will be selected. + If the priority is None then the ``applies`` method must return + a priority value. """ default_layout = param.ClassSelector(default=Row, class_=(Panel), @@ -137,27 +142,18 @@ class PaneBase(Reactive): # Whether applies requires full set of keywords _applies_kw: ClassVar[bool] = False + _skip_layoutable = ('css_classes', 'margin', 'name') + # Whether the Pane layout can be safely unpacked _unpack: ClassVar[bool] = True - # Declares whether Pane supports updates to the Bokeh model - _updates: ClassVar[bool] = False - - # Mapping from parameter name to bokeh model property name - _rename: ClassVar[Mapping[str, str | None]] = { - 'default_layout': None, 'loading': None - } - - # List of parameters that trigger a rerender of the Bokeh model - _rerender_params: ClassVar[list[str]] = ['object'] - - _skip_layoutable = ('css_classes', 'margin', 'name') - __abstract = True def __init__(self, object=None, **params): self._object_changing = False super().__init__(object=object, **params) + if not hasattr(self, '_internal_callbacks'): + self._internal_callbacks = [] applies = self.applies(self.object, **(params if self._applies_kw else {})) if (isinstance(applies, bool) and not applies) and self.object is not None: self._type_error(self.object) @@ -167,10 +163,9 @@ def __init__(self, object=None, **params): k not in self._skip_layoutable } self.layout = self.default_layout(self, **kwargs) - self._internal_callbacks.extend([ - self.param.watch(self._sync_layoutable, list(Layoutable.param)), - self.param.watch(self._update_pane, self._rerender_params) - ]) + self._internal_callbacks.append( + self.param.watch(self._sync_layoutable, list(Layoutable.param)) + ) self._sync_layoutable() def _validate_ref(self, pname, value): @@ -223,6 +218,100 @@ def __getitem__(self, index: int | str) -> Viewable: """ return self.layout[index] + @classmethod + def applies(cls, obj: Any) -> float | bool | None: + """ + Returns boolean or float indicating whether the Pane + can render the object. + + If the priority of the pane is set to + `None`, this method may also be used to define a float priority + depending on the object being rendered. + """ + return None + + @classmethod + def get_pane_type(cls, obj: Any, **kwargs) -> type['PaneBase']: + """ + Returns the applicable Pane type given an object by resolving + the precedence of all types whose applies method declares that + the object is supported. + + Arguments + --------- + obj (object): The object type to return a Pane type for + + Returns + ------- + The applicable Pane type with the highest precedence. + """ + if isinstance(obj, Viewable): + return type(obj) + descendents = [] + for p in param.concrete_descendents(PaneBase).values(): + if p.priority is None: + applies = True + try: + priority = p.applies(obj, **(kwargs if p._applies_kw else {})) + except Exception: + priority = False + else: + applies = None + priority = p.priority + if isinstance(priority, bool) and priority: + raise ValueError('If a Pane declares no priority ' + 'the applies method should return a ' + 'priority value specific to the ' + f'object type or False, but the {p.__name__} pane ' + 'declares no priority.') + elif priority is None or priority is False: + continue + descendents.append((priority, applies, p)) + pane_types = reversed(sorted(descendents, key=lambda x: x[0])) + for _, applies, pane_type in pane_types: + if applies is None: + try: + applies = pane_type.applies(obj, **(kwargs if pane_type._applies_kw else {})) + except Exception: + applies = False + if not applies: + continue + return pane_type + raise TypeError(f'{type(obj).__name__} type could not be rendered.') + + +class Pane(PaneBase, Reactive): + """ + Pane is the abstract baseclass for all atomic displayable units + in the Panel library. + + Panes defines an extensible interface for wrapping arbitrary + objects and transforming them into renderable components. + + Panes are reactive in the sense that when the object they are + wrapping is replaced or modified the UI will reflect the updated + object. + """ + + # Declares whether Pane supports updates to the Bokeh model + _updates: ClassVar[bool] = False + + # Mapping from parameter name to bokeh model property name + _rename: ClassVar[Mapping[str, str | None]] = { + 'default_layout': None, 'loading': None + } + + # List of parameters that trigger a rerender of the Bokeh model + _rerender_params: ClassVar[list[str]] = ['object'] + + __abstract = True + + def __init__(self, object=None, **params): + super().__init__(object=object, **params) + self._internal_callbacks.append( + self.param.watch(self._update_pane, self._rerender_params) + ) + #---------------------------------------------------------------- # Callback API #---------------------------------------------------------------- @@ -356,18 +445,6 @@ def _get_root_model( # Public API #---------------------------------------------------------------- - @classmethod - def applies(cls, obj: Any) -> float | bool | None: - """ - Returns boolean or float indicating whether the Pane - can render the object. - - If the priority of the pane is set to - `None`, this method may also be used to define a float priority - depending on the object being rendered. - """ - return None - def clone(self: T, object: Optional[Any] = None, **params) -> T: """ Makes a copy of the Pane sharing the same parameters. @@ -422,57 +499,8 @@ def get_root( state._views[ref] = (root_view, root, doc, comm) return root - @classmethod - def get_pane_type(cls, obj: Any, **kwargs) -> type['PaneBase']: - """ - Returns the applicable Pane type given an object by resolving - the precedence of all types whose applies method declares that - the object is supported. - - Arguments - --------- - obj (object): The object type to return a Pane type for - - Returns - ------- - The applicable Pane type with the highest precedence. - """ - if isinstance(obj, Viewable): - return type(obj) - descendents = [] - for p in param.concrete_descendents(PaneBase).values(): - if p.priority is None: - applies = True - try: - priority = p.applies(obj, **(kwargs if p._applies_kw else {})) - except Exception: - priority = False - else: - applies = None - priority = p.priority - if isinstance(priority, bool) and priority: - raise ValueError('If a Pane declares no priority ' - 'the applies method should return a ' - 'priority value specific to the ' - f'object type or False, but the {p.__name__} pane ' - 'declares no priority.') - elif priority is None or priority is False: - continue - descendents.append((priority, applies, p)) - pane_types = reversed(sorted(descendents, key=lambda x: x[0])) - for _, applies, pane_type in pane_types: - if applies is None: - try: - applies = pane_type.applies(obj, **(kwargs if pane_type._applies_kw else {})) - except Exception: - applies = False - if not applies: - continue - return pane_type - raise TypeError(f'{type(obj).__name__} type could not be rendered.') - -class ModelPane(PaneBase): +class ModelPane(Pane): """ ModelPane provides a baseclass that allows quickly wrapping a Bokeh model and translating parameters defined on the class @@ -524,7 +552,7 @@ def _process_param_change(self, params): return super()._process_param_change(params) -class ReplacementPane(PaneBase): +class ReplacementPane(Pane): """ ReplacementPane provides a baseclass for dynamic components that may have to dynamically update or switch out their contents, e.g. diff --git a/panel/pane/holoviews.py b/panel/pane/holoviews.py index b55639bf40..cc3ea591f2 100644 --- a/panel/pane/holoviews.py +++ b/panel/pane/holoviews.py @@ -27,7 +27,7 @@ ) from ..viewable import Layoutable, Viewable from ..widgets import Player -from .base import PaneBase, RerenderError, panel +from .base import Pane, RerenderError, panel from .plot import Bokeh, Matplotlib from .plotly import Plotly @@ -43,7 +43,7 @@ def check_holoviews(version): return Version(Version(hv.__version__).base_version) >= Version(version) -class HoloViews(PaneBase): +class HoloViews(Pane): """ `HoloViews` panes render any `HoloViews` object using the currently selected backend ('bokeh' (default), 'matplotlib' or 'plotly'). @@ -118,7 +118,7 @@ class HoloViews(PaneBase): 'right_bottom': (Row, 'end', False) } - _panes: ClassVar[Mapping[str, type[PaneBase]]] = { + _panes: ClassVar[Mapping[str, type[Pane]]] = { 'bokeh': Bokeh, 'matplotlib': Matplotlib, 'plotly': Plotly } @@ -568,7 +568,7 @@ def jslink(self, target, code=None, args=None, bidirectional=False, **links): return Link(self, target, properties=links, code=code, args=args, bidirectional=bidirectional) - jslink.__doc__ = PaneBase.jslink.__doc__ + jslink.__doc__ = Pane.jslink.__doc__ @classmethod def widgets_from_dimensions(cls, object, widget_types=None, widgets_type='individual', direction='vertical'): @@ -583,7 +583,7 @@ def widgets_from_dimensions(cls, object, widget_types=None, widgets_type='indivi from ..widgets import ( DatetimeInput, DiscreteSlider, FloatSlider, IntSlider, Select, - Widget, + WidgetBase, ) if widget_types is None: @@ -641,7 +641,7 @@ def widgets_from_dimensions(cls, object, widget_types=None, widgets_type='indivi nframes *= len(vals) elif dim.name in widget_types: widget = widget_types[dim.name] - if isinstance(widget, Widget): + if isinstance(widget, WidgetBase): widget.param.update(**kwargs) if not widget.name: widget.name = dim.label @@ -650,7 +650,7 @@ def widgets_from_dimensions(cls, object, widget_types=None, widgets_type='indivi elif isinstance(widget, dict): widget_type = widget.get('type', widget_type) widget_kwargs = dict(widget) - elif isinstance(widget, type) and issubclass(widget, Widget): + elif isinstance(widget, type) and issubclass(widget, WidgetBase): widget_type = widget else: raise ValueError('Explicit widget definitions expected ' @@ -698,7 +698,7 @@ def widgets_from_dimensions(cls, object, widget_types=None, widgets_type='indivi return widgets, dim_values -class Interactive(PaneBase): +class Interactive(Pane): object = param.Parameter(default=None, allow_refs=False, doc=""" The object being wrapped, which will be converted to a diff --git a/panel/pane/ipywidget.py b/panel/pane/ipywidget.py index d98fbfd509..a8a9c522f5 100644 --- a/panel/pane/ipywidget.py +++ b/panel/pane/ipywidget.py @@ -13,7 +13,7 @@ from ..config import config from ..models import IPyWidget as _BkIPyWidget -from .base import PaneBase +from .base import Pane if TYPE_CHECKING: from bokeh.document import Document @@ -21,7 +21,7 @@ from pyviz_comms import Comm -class IPyWidget(PaneBase): +class IPyWidget(Pane): """ The IPyWidget pane renders any ipywidgets model both in the notebook and in a deployed server. diff --git a/panel/pane/plot.py b/panel/pane/plot.py index cd1e031b3d..0ede8f7dcb 100644 --- a/panel/pane/plot.py +++ b/panel/pane/plot.py @@ -24,7 +24,7 @@ from ..io.notebook import push from ..util import escape from ..viewable import Layoutable -from .base import PaneBase +from .base import Pane from .image import ( PDF, PNG, SVG, Image, ) @@ -59,7 +59,7 @@ def _wrap_callback(cb, wrapped, doc, comm, callbacks): doc.hold(hold) -class Bokeh(PaneBase): +class Bokeh(Pane): """ The Bokeh pane allows displaying any displayable Bokeh model inside a Panel app. diff --git a/panel/pane/textual.py b/panel/pane/textual.py index b46710f355..d3d0bde920 100644 --- a/panel/pane/textual.py +++ b/panel/pane/textual.py @@ -9,7 +9,7 @@ from ..io.state import state from ..viewable import Viewable from ..widgets import Terminal -from .base import PaneBase +from .base import Pane if TYPE_CHECKING: from bokeh.document import Document @@ -17,7 +17,7 @@ from pyviz_comms import Comm -class Textual(PaneBase): +class Textual(Pane): """ The `Textual` pane provides a wrapper around a Textual App component, rendering it inside a Terminal and running it on the existing Panel diff --git a/panel/pane/vtk/vtk.py b/panel/pane/vtk/vtk.py index 0e1c9bf49d..1df5ed8081 100644 --- a/panel/pane/vtk/vtk.py +++ b/panel/pane/vtk/vtk.py @@ -23,7 +23,7 @@ from ...param import ParamMethod from ...util import isfile, lazy_load -from ..base import PaneBase +from ..base import Pane from ..plot import Bokeh from .enums import PRESET_CMAPS @@ -35,7 +35,7 @@ base64encode = lambda x: base64.b64encode(x).decode('utf-8') -class AbstractVTK(PaneBase): +class AbstractVTK(Pane): axes = param.Dict(default={}, nested_refs=True, doc=""" Parameters of the axes to construct in the 3d view. diff --git a/panel/param.py b/panel/param.py index 7892e71c1c..d4bc1e8943 100644 --- a/panel/param.py +++ b/panel/param.py @@ -50,7 +50,7 @@ class Skip(RuntimeError): Column, HSpacer, Panel, Row, Spacer, Tabs, WidgetBox, ) from .pane import DataFrame as DataFramePane -from .pane.base import PaneBase, ReplacementPane +from .pane.base import Pane, ReplacementPane from .reactive import Reactive from .util import ( abbreviated_repr, flatten, full_groupby, fullpath, is_parameterized, @@ -63,7 +63,7 @@ class Skip(RuntimeError): DateRangeSlider, DatetimeInput, DatetimeRangeSlider, DiscreteSlider, FileInput, FileSelector, FloatInput, FloatSlider, IntInput, IntSlider, LiteralInput, MultiSelect, RangeSlider, Select, StaticText, Tabulator, - TextInput, Toggle, Widget, + TextInput, Toggle, Widget, WidgetBase, ) from .widgets.button import _ButtonBase @@ -127,7 +127,7 @@ def set_values(*parameterizeds, **param_values): parameterized.param.update(**old_values) -class Param(PaneBase): +class Param(Pane): """ Param panes render a Parameterized class to a set of widgets which are linked to the parameter values on the class. @@ -358,7 +358,7 @@ def _update_widgets(self, *events): def _link_subobjects(self): for pname, widget in self._widgets.items(): - widgets = [widget] if isinstance(widget, Widget) else widget + widgets = [widget] if isinstance(widget, WidgetBase) else widget if not any(is_parameterized(getattr(w, 'value', None)) or any(is_parameterized(o) for o in getattr(w, 'options', [])) for w in widgets): @@ -451,7 +451,7 @@ def widget(self, p_name): value = getattr(self.object, p_name) allow_None = p_obj.allow_None or False - if isinstance(widget_class, type) and issubclass(widget_class, Widget): + if isinstance(widget_class, type) and issubclass(widget_class, WidgetBase): allow_None &= widget_class.param.value.allow_None if value is not None or allow_None: kw['value'] = value @@ -509,7 +509,7 @@ def widget(self, p_name): if isinstance(widget_class, type) and issubclass(widget_class, Button): kwargs.pop('value', None) - if isinstance(widget_class, Widget): + if isinstance(widget_class, WidgetBase): widget = widget_class else: widget = widget_class(**kwargs) @@ -1096,7 +1096,7 @@ def eval(self, ref): return eval_function_with_deps(ref) -class ReactiveExpr(PaneBase): +class ReactiveExpr(Pane): """ ReactiveExpr generates a UI for param.rx objects by rendering the widgets and outputs. diff --git a/panel/reactive.py b/panel/reactive.py index 0d375bcef2..c462533cf4 100644 --- a/panel/reactive.py +++ b/panel/reactive.py @@ -5,10 +5,12 @@ """ from __future__ import annotations +import asyncio import datetime as dt import difflib import inspect import logging +import pathlib import re import sys import textwrap @@ -20,6 +22,7 @@ TYPE_CHECKING, Any, Callable, ClassVar, Mapping, Optional, Union, ) +import jinja2 import numpy as np import param @@ -46,7 +49,10 @@ from .util import ( HTML_SANITIZER, classproperty, edit_readonly, escape, updating, ) -from .viewable import Layoutable, Renderable, Viewable +from .util.checks import import_available +from .viewable import ( + Child, Layoutable, Renderable, Viewable, +) if TYPE_CHECKING: import pandas as pd @@ -132,6 +138,9 @@ def __init__(self, **params): # A dictionary of bokeh property changes being processed self._changing = {} + # Whether the component is watching the stylesheets + self._watching_stylesheets = False + # Sets up watchers to process manual updates to models if self._manual_params: self._internal_callbacks.append( @@ -203,7 +212,7 @@ def _process_param_change(self, msg: dict[str, Any]) -> dict[str, Any]: stylesheets += properties['stylesheets'] wrapped = [] for stylesheet in stylesheets: - if isinstance(stylesheet, str) and stylesheet.endswith('.css'): + if isinstance(stylesheet, str) and stylesheet.split('?')[0].endswith('.css'): stylesheet = ImportedStyleSheet(url=stylesheet) wrapped.append(stylesheet) properties['stylesheets'] = wrapped @@ -348,6 +357,9 @@ def _cleanup(self, root: Model | None) -> None: model, _ = self._models.pop(ref, None) model._callbacks = {} model._event_callbacks = {} + if not self._models and self._watching_stylesheets: + self._watching_stylesheets.set() + self._watching_stylesheets = False comm, client_comm = self._comms.pop(ref, (None, None)) if comm: try: @@ -366,6 +378,27 @@ def _update_properties( changes = {event.name: event.new for event in events} return self._process_param_change(changes) + def _setup_autoreload(self): + from .config import config + paths = [sts for sts in self._stylesheets if isinstance(sts, pathlib.PurePath)] + if (self._watching_stylesheets or not (config.autoreload and paths and import_available('watchfiles'))): + return + self._watching_stylesheets = asyncio.Event() + state.execute(self._watch_stylesheets) + + async def _watch_stylesheets(self): + import watchfiles + base_dir = pathlib.Path(inspect.getfile(type(self))).parent + paths = [] + for sts in self._stylesheets: + if isinstance(sts, pathlib.PurePath): + if not sts.absolute().is_file(): + sts = base_dir / sts + if sts.is_file(): + paths.append(sts) + async for _ in watchfiles.awatch(*paths, stop_event=self._watching_stylesheets): + self.param.trigger('stylesheets') + def _param_change(self, *events: param.parameterized.Event) -> None: named_events = {event.name: event for event in events} for ref, (model, _) in self._models.copy().items(): @@ -545,7 +578,8 @@ class Reactive(Syncable, Viewable): def __init__(self, refs=None, **params): for name, pobj in self.param.objects('existing').items(): - if name not in self._param__private.explicit_no_refs: + if (name not in self._param__private.explicit_no_refs and + not isinstance(pobj, Child)): pobj.allow_refs = True if refs is not None: self._refs = refs @@ -1335,18 +1369,20 @@ def _process_events(self, events: dict[str, Any]) -> None: super(ReactiveData, self)._process_events(events) +class ReactiveMetaBase(ParameterizedMetaclass): -class ReactiveHTMLMetaclass(ParameterizedMetaclass): + _loaded_extensions: ClassVar[set[str]] = set() + + _name_counter: ClassVar[Counter] = Counter() + + +class ReactiveHTMLMetaclass(ReactiveMetaBase): """ Parses the ReactiveHTML._template of the class and initializes variables, callbacks and the data model to sync the parameters and HTML attributes. """ - _loaded_extensions: ClassVar[set[str]] = set() - - _name_counter: ClassVar[Counter] = Counter() - _script_regex: ClassVar[str] = r"script\([\"|'](.*)[\"|']\)" def __init__(mcs, name: str, bases: tuple[type, ...], dict_: Mapping[str, Any]): @@ -1443,13 +1479,64 @@ def __init__(mcs, name: str, bases: tuple[type, ...], dict_: Mapping[str, Any]): # Create model with unique name ReactiveHTMLMetaclass._name_counter[name] += 1 model_name = f'{name}{ReactiveHTMLMetaclass._name_counter[name]}' + mcs._data_model = construct_data_model( mcs, name=model_name, ignore=ignored, types=types ) +class ReactiveCustomBase(Reactive): + + _extension_name: ClassVar[Optional[str]] = None + + __css__: ClassVar[Optional[list[str]]] = None + __javascript__: ClassVar[Optional[list[str]]] = None + __javascript_modules__: ClassVar[Optional[list[str]]] = None + + @classmethod + def _loaded(cls) -> bool: + """ + Whether the component has been loaded. + """ + return ( + cls._extension_name is None or + (cls._extension_name in ReactiveMetaBase._loaded_extensions and + (state._extensions is None or (cls._extension_name in state._extensions))) + ) + + def _process_param_change(self, params): + props = super()._process_param_change(params) + if 'stylesheets' in params: + css = getattr(self, '__css__', []) or [] + if state.rel_path: + css = [ + ss if ss.startswith('http') else f'{state.rel_path}/{ss}' + for ss in css + ] + props['stylesheets'] = [ + ImportedStyleSheet(url=ss) for ss in css + ] + props['stylesheets'] + return props + + def _set_on_model(self, msg: Mapping[str, Any], root: Model, model: Model) -> None: + if not msg: + return + old = self._changing.get(root.ref['id'], []) + self._changing[root.ref['id']] = [ + attr for attr, value in msg.items() + if not model.lookup(attr).property.matches(getattr(model, attr), value) + ] + try: + model.update(**msg) + finally: + if old: + self._changing[root.ref['id']] = old + else: + del self._changing[root.ref['id']] + + -class ReactiveHTML(Reactive, metaclass=ReactiveHTMLMetaclass): +class ReactiveHTML(ReactiveCustomBase, metaclass=ReactiveHTMLMetaclass): """ The ReactiveHTML class enables you to create custom Panel components using HTML, CSS and/ or Javascript and without the complexities of Javascript build tools. @@ -1590,8 +1677,6 @@ class ReactiveHTML(Reactive, metaclass=ReactiveHTMLMetaclass): _dom_events: ClassVar[Mapping[str, list[str]]] = {} - _extension_name: ClassVar[Optional[str]] = None - _template: ClassVar[str] = "" _scripts: ClassVar[Mapping[str, str | list[str]]] = {} @@ -1600,10 +1685,6 @@ class ReactiveHTML(Reactive, metaclass=ReactiveHTMLMetaclass): r'data\.([^[^\d\W]\w*)[ ]*[\+,\-,\*,\\,%,\*\*,<<,>>,>>>,&,\^,|,\&\&,\|\|,\?\?]*=' ) - __css__: ClassVar[Optional[list[str]]] = None - __javascript__: ClassVar[Optional[list[str]]] = None - __javascript_modules__: ClassVar[Optional[list[str]]] = None - __abstract = True def __init__(self, **params): @@ -1634,17 +1715,6 @@ def __init__(self, **params): self._panes = {} self._event_callbacks = defaultdict(lambda: defaultdict(list)) - @classmethod - def _loaded(cls) -> bool: - """ - Whether the component has been loaded. - """ - return ( - cls._extension_name is None or - (cls._extension_name in ReactiveHTMLMetaclass._loaded_extensions and - (state._extensions is None or (cls._extension_name in state._extensions))) - ) - def _cleanup(self, root: Model | None = None) -> None: for _child, panes in self._panes.items(): for pane in panes: @@ -1799,8 +1869,6 @@ def _get_children( return self._process_children(doc, root, model, comm, new_models) def _get_template(self) -> tuple[str, list[str], Mapping[str, list[tuple[str, list[str], str]]]]: - import jinja2 - # Replace loop variables with indexed child parameter e.g.: # {% for obj in objects %} # ${obj} @@ -1924,18 +1992,21 @@ def _get_model( f'\n\npn.extension(\'{self._extension_name}\')\n' ) if self._extension_name: - ReactiveHTMLMetaclass._loaded_extensions.add(self._extension_name) + ReactiveMetaBase._loaded_extensions.add(self._extension_name) if not root: root = model ref = root.ref['id'] data_model: DataModel = model.data # type: ignore + for v in data_model.properties_with_values(): + if isinstance(v, DataModel): + v.tags.append(f"__ref:{ref}") self._patch_datamodel_ref(data_model.properties_with_values(), ref) model.update(children=self._get_children(doc, root, model, comm)) self._register_events('dom_event', model=model, doc=doc, comm=comm) self._link_props(data_model, self._linked_properties, doc, root, comm) - self._models[ref] = (model, parent) + self._models[root.ref['id']] = (model, parent) return model def _process_event(self, event: 'Event') -> None: @@ -1962,23 +2033,9 @@ def match(node, pattern): cb(event) def _set_on_model(self, msg: Mapping[str, Any], root: Model, model: Model) -> None: - if not msg: - return - ref = root.ref['id'] - old = self._changing.get(ref, []) - self._changing[ref] = [ - attr for attr, value in msg.items() - if not model.lookup(attr).property.matches(getattr(model, attr), value) - ] - try: - model.update(**msg) - finally: - if old: - self._changing[ref] = old - else: - del self._changing[ref] + super()._set_on_model(msg, root, model) if isinstance(model, DataModel): - self._patch_datamodel_ref(model.properties_with_values(), ref) + self._patch_datamodel_ref(model.properties_with_values(), root.ref['id']) def _update_model( self, events: dict[str, param.parameterized.Event], msg: dict[str, Any], diff --git a/panel/styles/models/esm.less b/panel/styles/models/esm.less new file mode 100644 index 0000000000..41f1739dfa --- /dev/null +++ b/panel/styles/models/esm.less @@ -0,0 +1,17 @@ +.error { + padding: 0.75rem 1.25rem; + border: 1px solid transparent; + border-radius: 0.25rem; + color: var(--danger-text-color); + background-color: var(--danger-bg-subtle); + border-color: var(--danger-border-subtle); +} + +.error .msg { + font-weight: bold; +} + +.error pre.highlight { + backdrop-filter: brightness(0.8); + font-weight: 900; +} diff --git a/panel/tests/chat/test_message.py b/panel/tests/chat/test_message.py index 422f18a7e9..1a0bbe41b0 100644 --- a/panel/tests/chat/test_message.py +++ b/panel/tests/chat/test_message.py @@ -41,8 +41,10 @@ def test_layout(self): assert isinstance(user_pane, HTML) assert user_pane.object == "User" - assert header_row[1] == "Header Test" - assert header_row[2] == "Header 2" + assert isinstance(header_row[1], Markdown) + assert header_row[1].object == "Header Test" + assert isinstance(header_row[2], Markdown) + assert header_row[2].object == "Header 2" center_row = columns[1][1] assert isinstance(center_row, Row) @@ -57,8 +59,10 @@ def test_layout(self): footer_col = columns[1][2] assert isinstance(footer_col, Column) - assert footer_col[0] == "Footer Test" - assert footer_col[1] == "Footer 2" + assert isinstance(footer_col[0], Markdown) + assert footer_col[0].object == "Footer Test" + assert isinstance(footer_col[1], Markdown) + assert footer_col[1].object == "Footer 2" timestamp_pane = footer_col[2] assert isinstance(timestamp_pane, HTML) diff --git a/panel/tests/conftest.py b/panel/tests/conftest.py index f592ac635b..a47114a2de 100644 --- a/panel/tests/conftest.py +++ b/panel/tests/conftest.py @@ -422,6 +422,15 @@ def py_file(): tf.close() os.unlink(tf.name) +@pytest.fixture +def js_file(): + tf = tempfile.NamedTemporaryFile(mode='w', suffix='.js', delete=False) + try: + yield tf + finally: + tf.close() + os.unlink(tf.name) + @pytest.fixture def py_files(): tf = tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) diff --git a/panel/tests/layout/test_feed.py b/panel/tests/layout/test_feed.py index dd5e3e48ac..91b834aa3f 100644 --- a/panel/tests/layout/test_feed.py +++ b/panel/tests/layout/test_feed.py @@ -10,4 +10,4 @@ def test_feed_init(document, comm): def test_feed_set_objects(document, comm): feed = Feed(height=100) feed.objects = list(range(1000)) - assert feed.objects == list(range(1000)) + assert [o.object for o in feed.objects] == list(range(1000)) diff --git a/panel/tests/test_custom.py b/panel/tests/test_custom.py new file mode 100644 index 0000000000..03ba2e27f9 --- /dev/null +++ b/panel/tests/test_custom.py @@ -0,0 +1,85 @@ +import param + +from panel.custom import ReactiveESM +from panel.pane import Markdown +from panel.viewable import Viewable + + +class ESMWithChildren(ReactiveESM): + + child = param.ClassSelector(class_=Viewable) + + children = param.List(item_type=Viewable) + + +def test_reactive_esm_model_cleanup(document, comm): + esm = ReactiveESM() + + model = esm.get_root(document, comm) + + ref = model.ref['id'] + assert ref in esm._models + assert esm._models[ref] == (model, None) + + esm._cleanup(model) + assert esm._models == {} + +def test_reactive_esm_child_model_cleanup(document, comm): + md = Markdown('foo') + esm = ESMWithChildren(child=md) + + model = esm.get_root(document, comm) + + ref = model.ref['id'] + assert ref in md._models + + md._cleanup(model) + assert md._models == {} + +def test_reactive_esm_child_model_cleanup_on_replace(document, comm): + md1 = Markdown('foo') + esm = ESMWithChildren(child=md1) + + model = esm.get_root(document, comm) + + ref = model.ref['id'] + assert ref in md1._models + md1_model, _ = md1._models[ref] + assert model.data.child is md1_model + + esm.child = md2 = Markdown('bar') + + assert md1._models == {} + assert ref in md2._models + md2_model, _ = md2._models[ref] + assert model.data.child is md2_model + +def test_reactive_esm_children_models_cleanup(document, comm): + md = Markdown('foo') + esm = ESMWithChildren(children=[md]) + + model = esm.get_root(document, comm) + + ref = model.ref['id'] + assert ref in md._models + + md._cleanup(model) + assert md._models == {} + +def test_reactive_esm_children_models_cleanup_on_replace(document, comm): + md1 = Markdown('foo') + esm = ESMWithChildren(children=[md1]) + + model = esm.get_root(document, comm) + + ref = model.ref['id'] + assert ref in md1._models + md1_model, _ = md1._models[ref] + assert model.data.children == [md1_model] + + esm.children = [md2] = [Markdown('bar')] + + assert md1._models == {} + assert ref in md2._models + md2_model, _ = md2._models[ref] + assert model.data.children == [md2_model] diff --git a/panel/tests/test_docs.py b/panel/tests/test_docs.py index 0242fcf119..0b0d760323 100644 --- a/panel/tests/test_docs.py +++ b/panel/tests/test_docs.py @@ -59,7 +59,7 @@ def is_panel_widget(attr): @ref_available def test_panes_are_in_reference_gallery(): exceptions = { - "PaneBase", "YT", "RGGPlot", "Interactive", "ICO", "Image", + "PaneBase", "Pane", "YT", "RGGPlot", "Interactive", "ICO", "Image", "IPyLeaflet", "ParamFunction", "ParamMethod", "ParamRef" } docs = {f.with_suffix("").name for f in (REF_PATH / "panes").iterdir()} diff --git a/panel/tests/test_reactive.py b/panel/tests/test_reactive.py index 9b0e2582fd..232a86e491 100644 --- a/panel/tests/test_reactive.py +++ b/panel/tests/test_reactive.py @@ -127,18 +127,17 @@ def test_text_input_controls(): wb1, wb2 = controls assert isinstance(wb1, WidgetBox) assert len(wb1) == 7 - name, disabled, *(ws) = wb1 + name, value, disabled, *(ws) = wb1 + assert isinstance(value, TextInput) + text_input.value = "New value" + assert value.value == "New value" assert isinstance(name, StaticText) assert isinstance(disabled, Checkbox) not_checked = [] for w in ws: - if w.name == 'Value': - assert isinstance(w, TextInput) - text_input.value = "New value" - assert w.value == "New value" - elif w.name == 'Value input': + if w.name == 'Value input': assert isinstance(w, TextInput) elif w.name == 'Placeholder': assert isinstance(w, TextInput) diff --git a/panel/tests/ui/test_custom.py b/panel/tests/ui/test_custom.py new file mode 100644 index 0000000000..d8c2ef406d --- /dev/null +++ b/panel/tests/ui/test_custom.py @@ -0,0 +1,486 @@ +import pathlib + +import param +import pytest + +pytest.importorskip("playwright") + +from playwright.sync_api import expect + +from panel.custom import ( + AnyWidgetComponent, Child, Children, JSComponent, ReactComponent, +) +from panel.layout import Row +from panel.tests.util import serve_component, wait_until + +pytestmark = pytest.mark.ui + + +class JSUpdate(JSComponent): + + text = param.String() + + _esm = """ + export function render({ model }) { + const h1 = document.createElement('h1') + h1.textContent = model.text + model.on('text', () => { + h1.textContent = model.text; + }) + return h1 + } + """ + +class ReactUpdate(ReactComponent): + + text = param.String() + + _esm = """ + export function render({ model }) { + const [text, setText ] = model.useState("text") + return

{text}

+ } + """ + +class AnyWidgetUpdate(AnyWidgetComponent): + + text = param.String() + + _esm = """ + export function render({ model, el }) { + const h1 = document.createElement('h1') + h1.textContent = model.get("text") + model.on("change:text", () => { + h1.textContent = model.get("text"); + }) + el.append(h1) + } + """ + + +@pytest.mark.parametrize('component', [JSUpdate, ReactUpdate, AnyWidgetUpdate]) +def test_update(page, component): + example = component(text='Hello World!') + + serve_component(page, example) + + expect(page.locator('h1')).to_have_text('Hello World!') + + example.text = "Foo!" + + expect(page.locator('h1')).to_have_text('Foo!') + + +class JSUnwatch(JSComponent): + + text = param.String() + + _esm = """ + export function render({ model, el }) { + const h1 = document.createElement('h1') + const h2 = document.createElement('h2') + h1.textContent = model.text + h2.textContent = model.text + const cb = () => { + h1.textContent = model.text; + } + const cb2 = () => { + h2.textContent = model.text; + model.off('text', cb2) + } + model.on('text', cb) + model.on('text', cb2) + el.append(h1, h2) + } + """ + +class AnyWidgetUnwatch(AnyWidgetComponent): + + text = param.String() + + _esm = """ + export function render({ model, el }) { + const h1 = document.createElement('h1') + const h2 = document.createElement('h2') + h1.textContent = model.get("text") + h2.textContent = model.get("text") + const cb = () => { + h1.textContent = model.get("text"); + } + const cb2 = () => { + h2.textContent = model.get("text"); + model.off("change:text", cb2) + } + model.on("change:text", cb) + model.on("change:text", cb2) + el.append(h1, h2) + } + """ + +@pytest.mark.parametrize('component', [JSUnwatch, AnyWidgetUnwatch]) +def test_unwatch(page, component): + example = component(text='Hello World!') + + serve_component(page, example) + + expect(page.locator('h1')).to_have_text('Hello World!') + expect(page.locator('h1')).to_have_text('Hello World!') + + example.text = "Foo!" + + expect(page.locator('h1')).to_have_text('Foo!') + expect(page.locator('h2')).to_have_text('Foo!') + + example.text = "Baz!" + + expect(page.locator('h1')).to_have_text('Baz!') + expect(page.locator('h2')).to_have_text('Foo!') + + +class JSInput(JSComponent): + + text = param.String() + + _esm = """ + export function render({ model }) { + const inp = document.createElement('input') + inp.id = 'input' + inp.value = model.text + inp.addEventListener('change', (event) => { + model.text = event.target.value; + }) + return inp + } + """ + + +class ReactInput(ReactComponent): + + text = param.String() + + _esm = """ + export function render({ model }) { + const [text, setText ] = model.useState("text") + return ( + setText(e.target.value)} + /> + ) + } + """ + + +@pytest.mark.parametrize('component', [JSInput, ReactInput]) +def test_gather_input(page, component): + example = component(text='Hello World!') + + serve_component(page, example) + + inp = page.locator('#input') + + inp.click() + for _ in example.text: + inp.press('Backspace') + inp.press_sequentially('Foo!') + inp.press('Enter') + + wait_until(lambda: example.text == 'Foo!', page) + + +class JSSendEvent(JSComponent): + + clicks = param.Integer(default=0) + + _esm = """ + export function render({ model }) { + const button = document.createElement('button') + button.id = 'button' + button.onclick = (event) => model.send_event('click', event) + return button + } + """ + + def _handle_click(self, event): + self.clicks += 1 + + +class ReactSendEvent(ReactComponent): + + clicks = param.Integer(default=0) + + _esm = """ + export function render({ model }) { + return + }""" + + +@pytest.mark.parametrize('component', [JSChild, ReactChild]) +def test_child(page, component): + example = component(child='A Markdown pane!') + + serve_component(page, example) + + expect(page.locator('button')).to_have_text('A Markdown pane!') + + example.child = 'A different Markdown pane!' + + expect(page.locator('button')).to_have_text('A different Markdown pane!') + + +class JSChildren(JSComponent): + + children = Children() + + _esm = """ + export function render({ model }) { + const div = document.createElement('div') + div.id = "container" + div.append(...model.get_child('children')) + return div + }""" + + +class ReactChildren(ReactComponent): + + children = Children() + + _esm = """ + export function render({ model }) { + return
{model.get_child("children")}
+ }""" + + +@pytest.mark.parametrize('component', [JSChildren, ReactChildren]) +def test_children(page, component): + example = component(children=['A Markdown pane!']) + + serve_component(page, example) + + expect(page.locator('#container')).to_have_text('A Markdown pane!') + + example.children = ['A different Markdown pane!'] + + expect(page.locator('#container')).to_have_text('A different Markdown pane!') + + example.children = ['
1
', '
2
'] + + expect(page.locator('.foo').nth(0)).to_have_text('1') + expect(page.locator('.foo').nth(1)).to_have_text('2') + +JS_CODE_BEFORE = """ +export function render() { + const h1 = document.createElement('h1') + h1.innerText = "foo" + return h1 +}""" + +JS_CODE_AFTER = """ +export function render() { + const h1 = document.createElement('h1') + h1.innerText = "bar" + return h1 +}""" + +REACT_CODE_BEFORE = """ +export function render() { + return

foo

+}""" + +REACT_CODE_AFTER = """ +export function render() { + return

bar

+}""" + +@pytest.mark.parametrize('component,before,after', [ + (JSComponent, JS_CODE_BEFORE, JS_CODE_AFTER), + (ReactChildren, REACT_CODE_BEFORE, REACT_CODE_AFTER), +]) +def test_reload(page, js_file, component, before, after): + js_file.file.write(before) + js_file.file.flush() + js_file.file.seek(0) + + class CustomReload(component): + _esm = pathlib.Path(js_file.name) + + example = CustomReload() + serve_component(page, example) + + expect(page.locator('h1')).to_have_text('foo') + + js_file.file.write(after) + js_file.file.flush() + js_file.file.seek(0) + example._update_esm() + + expect(page.locator('h1')).to_have_text('bar') + + +def test_anywidget_custom_event(page): + + class SendEvent(AnyWidgetComponent): + + _esm = """ + export function render({model, el}) { + const h1 = document.createElement('h1') + model.on("msg:custom", (msg) => { h1.innerText = msg.text }) + el.append(h1) + } + """ + + example = SendEvent() + serve_component(page, example) + + expect(page.locator('h1')).to_have_count(1) + + example.send({"type": "foo", "text": "bar"}) + + expect(page.locator('h1')).to_have_text("bar") + + +class JSLifecycleAfterRender(JSComponent): + + _esm = """ + export function render({ model }) { + const h1 = document.createElement('h1') + model.on('after_render', () => { h1.textContent = 'rendered' }) + return h1 + }""" + + +class ReactLifecycleAfterRender(ReactComponent): + + _esm = """ + import {useState} from "react" + + export function render({ model }) { + const [text, setText] = useState("") + model.on('after_render', () => { setText('rendered') }) + return

{text}

+ }""" + + +@pytest.mark.parametrize('component', [JSLifecycleAfterRender, ReactLifecycleAfterRender]) +def test_after_render_lifecycle_hooks(page, component): + example = component() + + serve_component(page, example) + + expect(page.locator('h1')).to_have_count(1) + + expect(page.locator('h1')).to_have_text("rendered") + + +class JSLifecycleAfterResize(JSComponent): + + _esm = """ + export function render({ model }) { + const h1 = document.createElement('h1') + h1.textContent = "0" + let count = 0 + model.on('after_resize', () => { count += 1; h1.textContent = `${count}`; }) + return h1 + }""" + +class ReactLifecycleAfterResize(ReactComponent): + + _esm = """ + import {useState} from "react" + + export function render({ model }) { + const [count, setCount] = useState(0) + model.on('after_resize', () => { setCount(count+1); }) + return

{count}

+ }""" + + +@pytest.mark.parametrize('component', [JSLifecycleAfterResize, ReactLifecycleAfterResize]) +def test_after_resize_lifecycle_hooks(page, component): + example = component(sizing_mode='stretch_width') + + serve_component(page, example) + + expect(page.locator('h1')).to_have_count(1) + + expect(page.locator('h1')).to_have_text("1") + + page.set_viewport_size({ "width": 50, "height": 300}) + + expect(page.locator('h1')).to_have_text("2") + + +class JSLifecycleRemove(JSComponent): + + _esm = """ + export function render({ model }) { + const h1 = document.createElement('h1') + h1.textContent = 'Hello' + model.on('remove', () => { console.warn('Removed') }) + return h1 + }""" + +class ReactLifecycleRemove(ReactComponent): + + _esm = """ + import {useState} from "react" + + export function render({ model }) { + const [count, setCount] = useState(0) + model.on('remove', () => { console.warn('Removed') }) + return

Hello

+ }""" + + +@pytest.mark.parametrize('component', [JSLifecycleRemove, ReactLifecycleRemove]) +def test_remove_lifecycle_hooks(page, component): + example = Row(component(sizing_mode='stretch_width')) + + serve_component(page, example) + + expect(page.locator('h1')).to_have_count(1) + + expect(page.locator('h1')).to_have_text("Hello") + + with page.expect_console_message() as msg_info: + example.clear() + + wait_until(lambda: msg_info.value.args[0].json_value() == "Removed", page) diff --git a/panel/util/checks.py b/panel/util/checks.py index 70648b8ac0..ed3cf748ca 100644 --- a/panel/util/checks.py +++ b/panel/util/checks.py @@ -1,6 +1,7 @@ from __future__ import annotations import datetime as dt +import importlib.util import os import sys @@ -122,3 +123,20 @@ def is_number(s: Any) -> bool: return True except ValueError: return False + + +def import_available(module: str): + """ + Checks whether a module can be imported + + Arguments + --------- + module: str + + Returns + ------- + available: bool + Whether the module is available to be imported + """ + spec = importlib.util.find_spec(module) + return spec is not None diff --git a/panel/viewable.py b/panel/viewable.py index 2649363d16..4c0bef4c7a 100644 --- a/panel/viewable.py +++ b/panel/viewable.py @@ -17,6 +17,7 @@ import sys import threading import traceback +import typing import uuid from typing import ( @@ -29,6 +30,8 @@ from bokeh.document import Document from bokeh.resources import Resources from jinja2 import Template +from param import Undefined +from param.parameterized import instance_descriptor from pyviz_comms import Comm # type: ignore from ._param import Align, Aspect, Margin @@ -1070,6 +1073,130 @@ def _repr_mimebundle_(self, include=None, exclude=None): return self._create_view()._repr_mimebundle_(include, exclude) +class Child(param.ClassSelector): + """ + A Parameter type that holds a single `Viewable` object. + + Given a non-`Viewable` object it will automatically promote it to a `Viewable` + by calling the `pn.panel` utility. + """ + + @typing.overload + def __init__( + self, + default=None, *, is_instance=True, allow_None=False, doc=None, + label=None, precedence=None, instantiate=True, constant=False, + readonly=False, pickle_default_value=True, per_instance=True, + allow_refs=False, nested_refs=False + ): + ... + + def __init__(self, /, default=Undefined, class_=Viewable, **params): + if isinstance(class_, type) and not issubclass(class_, Viewable): + raise TypeError( + f"Child.class_ must be an instance of Viewable, not {type(class_)}." + ) + elif isinstance(class_, tuple) and not all(issubclass(it, Viewable) for it in class_): + invalid = ' or '.join([str(type(it)) for it in class_ if issubclass(it, Viewable)]) + raise TypeError( + f"Child.class_ must be an instance of Viewable, not {invalid}." + ) + super().__init__(default=self._transform_value(default), class_=class_, **params) + + def _transform_value(self, val): + if not isinstance(val, Viewable) and val not in (None, Undefined): + from .pane import panel + val = panel(val) + return val + + @instance_descriptor + def __set__(self, obj, val): + super().__set__(obj, self._transform_value(val)) + + +class Children(param.List): + """ + A Parameter type that defines a list of ``Viewable`` objects. Given + a non-Viewable object it will automatically promote it to a ``Viewable`` + by calling the ``panel`` utility. + """ + + @typing.overload + def __init__( + self, + default=[], *, instantiate=True, bounds=(0, None), + allow_None=False, doc=None, label=None, precedence=None, + constant=False, readonly=False, pickle_default_value=True, per_instance=True, + allow_refs=False, nested_refs=False + ): + ... + + def __init__( + self, /, default=Undefined, instantiate=Undefined, bounds=Undefined, + item_type=Viewable, **params + ): + if isinstance(item_type, type) and not issubclass(item_type, Viewable): + raise TypeError( + f"Children.item_type must be an instance of Viewable, not {type(item_type)}." + ) + elif isinstance(item_type, tuple) and not all(issubclass(it, Viewable) for it in item_type): + invalid = ' or '.join([str(type(it)) for it in item_type if issubclass(it, Viewable)]) + raise TypeError( + f"Children.item_type must be an instance of Viewable, not {invalid}." + ) + elif 'item_type' in params: + raise ValueError("Children does not support item_type, use item_type instead.") + super().__init__( + default=self._transform_value(default), instantiate=instantiate, + item_type=item_type, **params + ) + + def _transform_value(self, val): + if isinstance(val, list) and val: + from .pane import panel + val[:] = [ + v if isinstance(v, Viewable) else panel(v) + for v in val + ] + return val + + @instance_descriptor + def __set__(self, obj, val): + super().__set__(obj, self._transform_value(val)) + + + +def is_viewable_param(parameter: param.Parameter) -> bool: + """ + Detects whether the Parameter uniquely identifies a Viewable + type. + + Arguments + --------- + parameter: param.Parameter + + Returns + ------- + Whether the Parameter specieis a Parameter type + """ + p = parameter + if ( + isinstance(p, (Child, Children)) or + (isinstance(p, param.ClassSelector) and p.class_ and ( + (isinstance(p.class_, tuple) and + all(issubclass(cls, Viewable) for cls in p.class_)) or + issubclass(p.class_, Viewable) + )) or + (isinstance(p, param.List) and p.item_type and ( + (isinstance(p.item_type, tuple) and + all(issubclass(cls, Viewable) for cls in p.item_type)) or + issubclass(p.item_type, Viewable) + )) + ): + return True + return False + + __all__ = ( "Layoutable", "Viewable", diff --git a/panel/widgets/__init__.py b/panel/widgets/__init__.py index ee8b249c4f..5a8c08d459 100644 --- a/panel/widgets/__init__.py +++ b/panel/widgets/__init__.py @@ -32,7 +32,7 @@ For more detail see the Getting Started Guide https://panel.holoviz.org/getting_started/index.html """ -from .base import CompositeWidget, Widget # noqa +from .base import CompositeWidget, Widget, WidgetBase # noqa from .button import Button, MenuButton, Toggle # noqa from .codeeditor import CodeEditor # noqa from .debugger import Debugger # noqa diff --git a/panel/widgets/base.py b/panel/widgets/base.py index 0336c2b5cc..dd2b705f49 100644 --- a/panel/widgets/base.py +++ b/panel/widgets/base.py @@ -32,7 +32,48 @@ T = TypeVar('T') -class Widget(Reactive): +class WidgetBase(param.Parameterized): + """ + WidgetBase provides an abstract baseclass for widget components + which can be used to implement a custom widget-like type without + implementing the methods associated with a Reactive Panel component, + e.g. it may be used as a mix-in to a PyComponent or JSComponent. + """ + + value = param.Parameter(allow_None=True, doc=""" + The widget value which the widget type resolves to when used + as a reactive param reference.""") + + __abstract = True + + @classmethod + def from_param(cls: type[T], parameter: param.Parameter, **params) -> T: + """ + Construct a widget from a Parameter and link the two + bi-directionally. + + Parameters + ---------- + parameter: param.Parameter + A parameter to create the widget from. + + Returns + ------- + Widget instance linked to the supplied parameter + """ + from ..param import Param + layout = Param( + parameter, widgets={parameter.name: dict(type=cls, **params)}, + display_threshold=-math.inf + ) + return layout[0] + + @property + def rx(self): + return self.param.value.rx + + +class Widget(Reactive, WidgetBase): """ Widgets allow syncing changes in bokeh widget models with the parameters on the Widget instance. @@ -73,28 +114,6 @@ def __init__(self, **params): self._param_pane = None super().__init__(**params) - @classmethod - def from_param(cls: type[T], parameter: param.Parameter, **params) -> T: - """ - Construct a widget from a Parameter and link the two - bi-directionally. - - Parameters - ---------- - parameter: param.Parameter - A parameter to create the widget from. - - Returns - ------- - Widget instance linked to the supplied parameter - """ - from ..param import Param - layout = Param( - parameter, widgets={parameter.name: dict(type=cls, **params)}, - display_threshold=-math.inf - ) - return layout[0] - @property def _linked_properties(self) -> tuple[str]: props = list(super()._linked_properties) @@ -102,10 +121,6 @@ def _linked_properties(self) -> tuple[str]: props.remove('description') return tuple(props) - @property - def rx(self): - return self.param.value.rx - def _process_param_change(self, params: dict[str, Any]) -> dict[str, Any]: params = super()._process_param_change(params) if self._widget_type is not None and 'stylesheets' in params: @@ -249,6 +264,6 @@ def _synced_params(self) -> list[str]: def _widget_transform(obj): - return obj.param.value if isinstance(obj, Widget) else obj + return obj.param.value if isinstance(obj, WidgetBase) else obj register_reference_transform(_widget_transform) diff --git a/panel/widgets/button.py b/panel/widgets/button.py index b8289649b5..83b08835d7 100644 --- a/panel/widgets/button.py +++ b/panel/widgets/button.py @@ -329,7 +329,9 @@ class MenuButton(_ButtonBase, _ClickButton, IconMixin): _event: ClassVar[str] = 'menu_item_click' - _rename: ClassVar[Mapping[str, str | None]] = {'name': 'label', 'items': 'menu', 'clicked': None} + _rename: ClassVar[Mapping[str, str | None]] = { + 'name': 'label', 'items': 'menu', 'clicked': None, 'value': None + } _widget_type: ClassVar[type[Model]] = _BkDropdown diff --git a/panel/widgets/codeeditor.py b/panel/widgets/codeeditor.py index 94d331c45b..0086bc298a 100644 --- a/panel/widgets/codeeditor.py +++ b/panel/widgets/codeeditor.py @@ -49,7 +49,7 @@ class CodeEditor(Widget): theme = param.ObjectSelector(default="chrome", objects=list(ace_themes), doc="Theme of the editor") - value = param.String(doc="State of the current code in the editor") + value = param.String(default="", doc="State of the current code in the editor") _rename: ClassVar[Mapping[str, str | None]] = {"value": "code", "name": None} diff --git a/panel/widgets/misc.py b/panel/widgets/misc.py index 23e48cf2f8..61edb8e4d8 100644 --- a/panel/widgets/misc.py +++ b/panel/widgets/misc.py @@ -144,7 +144,8 @@ class FileDownload(IconMixin): } _rename: ClassVar[Mapping[str, str | None]] = { - 'callback': None, 'button_style': None, 'file': None, '_clicks': 'clicks' + 'callback': None, 'button_style': None, 'file': None, '_clicks': 'clicks', + 'value': None } _stylesheets: ClassVar[list[str]] = [f'{CDN_DIST}css/button.css'] diff --git a/panel/widgets/slider.py b/panel/widgets/slider.py index 12ffab2376..93015b7a30 100644 --- a/panel/widgets/slider.py +++ b/panel/widgets/slider.py @@ -532,7 +532,7 @@ def values(self): class _RangeSliderBase(_SliderBase): - value = param.Tuple(length=2, allow_None=False, nested_refs=True, doc=""" + value = param.Tuple(default=(None, None), length=2, allow_None=False, nested_refs=True, doc=""" The selected range of the slider. Updated when a handle is dragged.""") value_start = param.Parameter(readonly=True, doc="""The lower value of the selected range.""") diff --git a/panel/widgets/speech_to_text.py b/panel/widgets/speech_to_text.py index 77b41538bd..93274af00f 100644 --- a/panel/widgets/speech_to_text.py +++ b/panel/widgets/speech_to_text.py @@ -382,7 +382,7 @@ class SpeechToText(Widget): results = param.List(constant=True, doc=""" The `results` as a list of Dictionaries.""") - value = param.String(constant=True, label="Last Result", doc=""" + value = param.String(default="", constant=True, label="Last Result", doc=""" The transcipt of the highest confidence RecognitionAlternative of the last RecognitionResult. Please note we strip the transcript for leading spaces.""") diff --git a/panel/widgets/terminal.py b/panel/widgets/terminal.py index aa63d411aa..85facf6020 100644 --- a/panel/widgets/terminal.py +++ b/panel/widgets/terminal.py @@ -248,7 +248,7 @@ class Terminal(Widget): nrows = param.Integer(readonly=True, doc=""" The number of rows in the terminal.""") - value = param.String(label="Input", readonly=True, doc=""" + value = param.String(default="", label="Input", readonly=True, doc=""" User input received from the Terminal. Sent one character at the time.""") write_to_console = param.Boolean(default=False, doc=""" diff --git a/panel/widgets/texteditor.py b/panel/widgets/texteditor.py index 7c50b31b48..12c2e5ae11 100644 --- a/panel/widgets/texteditor.py +++ b/panel/widgets/texteditor.py @@ -46,7 +46,7 @@ class TextEditor(Widget): placeholder = param.String(doc="Placeholder output when the editor is empty.") - value = param.String(doc="State of the current text in the editor") + value = param.String(default="", doc="State of the current text in the editor") _rename: ClassVar[Mapping[str, str | None]] = { 'name': 'name', 'value': 'text'