diff --git a/.github/workflows/jupyterlite.yaml b/.github/workflows/jupyterlite.yaml index e9a1f148ea..40523eec7d 100644 --- a/.github/workflows/jupyterlite.yaml +++ b/.github/workflows/jupyterlite.yaml @@ -3,59 +3,67 @@ name: jupyterlite on: push: tags: - - 'v[0-9]+.[0-9]+.[0-9]+' - - 'v[0-9]+.[0-9]+.[0-9]+a[0-9]+' - - 'v[0-9]+.[0-9]+.[0-9]+b[0-9]+' - - 'v[0-9]+.[0-9]+.[0-9]+rc[0-9]+' + - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+a[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+b[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+rc[0-9]+" workflow_dispatch: inputs: target: - description: 'Site to build and deploy' + description: "Site to build and deploy" type: choice options: - - dev - - main - - dryrun + - dev + - main + - dryrun required: true default: dryrun schedule: - - cron: '0 19 * * SUN' + - cron: "0 19 * * SUN" jobs: - deploy_jupyterlite: - name: JupyterLite + pixi_lock: + name: Pixi lock runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v3 + - uses: holoviz-dev/holoviz_tasks/pixi_lock@pixi + + lite_build: + name: Build Jupyterlite + needs: [pixi_lock] + runs-on: "ubuntu-latest" + steps: + - uses: holoviz-dev/holoviz_tasks/pixi_install@pixi + with: + environments: "lite" + install: false + download-data: false + - name: lite build + run: pixi run lite-build + - uses: actions/upload-artifact@v4 + if: always() with: - fetch-depth: 0 - - name: Setup Python - uses: actions/setup-python@v4 + name: jupyterlite + path: lite/dist/ + if-no-files-found: error + + lite_publish: + name: Publish Jupyterlite + runs-on: ubuntu-latest + needs: [lite_build] + steps: + - uses: actions/download-artifact@v4 with: - python-version: '3.10' - - name: Set and echo git ref + name: jupyterlite + path: lite/dist/ + - name: Set output id: vars - run: | - echo 'Deploying from ref ${GITHUB_REF#refs/*/}' - echo 'tag=${GITHUB_REF#refs/*/}' >> $GITHUB_OUTPUT - - name: Install the dependencies - run: | - python -m pip install -r ./lite/requirements.txt - - name: Build pyodide wheels for JupyterLite - run: | - python ./scripts/build_pyodide_wheels.py lite/pypi - - name: Convert content - run: | - python ./scripts/panelite/generate_panelite_content.py - - name: Build the JupyterLite site - run: | - jupyter lite build --lite-dir lite --output-dir lite/dist + run: echo "tag=${{ needs.docs_build.outputs.tag }}" >> $GITHUB_OUTPUT - name: Upload dev if: | (github.event_name == 'workflow_dispatch' && github.event.inputs.target == 'dev') || (github.event_name == 'workflow_run' && (contains(steps.vars.outputs.tag, 'a') || contains(steps.vars.outputs.tag, 'b') || contains(steps.vars.outputs.tag, 'rc'))) - uses: peaceiris/actions-gh-pages@v3 + uses: peaceiris/actions-gh-pages@v4 with: personal_token: ${{ secrets.ACCESS_TOKEN }} external_repository: holoviz-dev/panelite-dev @@ -65,7 +73,7 @@ jobs: if: | (github.event_name == 'workflow_dispatch' && github.event.inputs.target == 'main') || (github.event_name == 'push' && !(contains(steps.vars.outputs.tag, 'a') || contains(steps.vars.outputs.tag, 'b') || contains(steps.vars.outputs.tag, 'rc'))) - uses: peaceiris/actions-gh-pages@v3 + uses: peaceiris/actions-gh-pages@v4 with: personal_token: ${{ secrets.ACCESS_TOKEN }} external_repository: holoviz-dev/panelite diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 5da38ff007..f330f061b0 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -206,12 +206,7 @@ jobs: (jupyter lab --config panel/tests/ui/jupyter_server_test_config.py --port 8887 > /tmp/jupyterlab_server.log 2>&1) & - name: Build JupyterLite shell: pixi run -e test-ui bash -el {0} - run: | - # TODO: Make this a pixi feature/environment - python -m pip install -r ./lite/requirements.txt - python ./scripts/build_pyodide_wheels.py lite/pypi - python ./scripts/panelite/generate_panelite_content.py - jupyter lite build --lite-dir lite --output-dir lite/dist + run: pixi run -e lite lite-build - name: Wait for JupyterLab uses: ifaxity/wait-on-action@v1.2.1 with: @@ -257,3 +252,22 @@ jobs: - name: Test Unit run: | pixi run -e ${{ matrix.environment }} test-unit + + type_test_suite: + name: type:${{ matrix.environment }}:${{ matrix.os }} + needs: [pre_commit, setup, pixi_lock] + runs-on: ${{ matrix.os }} + if: needs.setup.outputs.code_change == 'true' + strategy: + fail-fast: false + matrix: + os: ["ubuntu-latest"] + environment: ["test-type"] + timeout-minutes: 120 + steps: + - uses: holoviz-dev/holoviz_tasks/pixi_install@pixi + with: + environments: ${{ matrix.environment }} + - name: Test Type + run: | + pixi run -e ${{ matrix.environment }} test-type || echo "Failed" diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index bea506eb72..177b3b9167 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -66,9 +66,15 @@ representative at an online or offline event. ## 👩‍⚖️ Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -coc@holoviz.org. +Instances of abusive, harassing, or otherwise unacceptable behavior +may be reported to the community leaders responsible for enforcement +at coc@holoviz.org, which is monitored by the [CoC +subcommittee](https://panel.holoviz.org/about/people.html#CoC-Subcommittee) +or a report can be made using the NumFOCUS Code of Conduct report +form. If community leaders cannot come to a resolution about +enforcement, reports will be escalated to the NumFocus Code of Conduct +committee (conduct@numfocus.org). + All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the diff --git a/README.md b/README.md index c8b12f06e0..072d059313 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Enjoying Panel? Show your support with a [Github star](https://github.com/holovi Downloads - +PyPi Downloads Conda Downloads Build Status diff --git a/binder/jupyter-panel-apps-server/jupyter_panel_apps_server.py b/binder/jupyter-panel-apps-server/jupyter_panel_apps_server.py index d661d41dac..847c6c6cbb 100644 --- a/binder/jupyter-panel-apps-server/jupyter_panel_apps_server.py +++ b/binder/jupyter-panel-apps-server/jupyter_panel_apps_server.py @@ -20,7 +20,7 @@ def get_apps(): return [ app for app in glob("examples/gallery/**/*.ipynb", recursive=True) - if not app in DONT_SERVE + if app not in DONT_SERVE ] diff --git a/codecov.yml b/codecov.yml index b6ee7c18e8..bec5dc77f5 100644 --- a/codecov.yml +++ b/codecov.yml @@ -7,4 +7,4 @@ coverage: threshold: 0.5% comment: - require_changes: yes + require_changes: true diff --git a/doc/_static/images/global-task-runner.png b/doc/_static/images/global-task-runner.png new file mode 100644 index 0000000000..ee7ddd185a Binary files /dev/null and b/doc/_static/images/global-task-runner.png differ diff --git a/doc/_static/images/serverside-video.png b/doc/_static/images/serverside-video.png new file mode 100644 index 0000000000..4a5052d4f9 Binary files /dev/null and b/doc/_static/images/serverside-video.png differ diff --git a/doc/_static/images/session-task-runner.png b/doc/_static/images/session-task-runner.png new file mode 100644 index 0000000000..c6f769597d Binary files /dev/null and b/doc/_static/images/session-task-runner.png differ diff --git a/doc/about/index.md b/doc/about/index.md index 6666523fb1..ccbb10c9d5 100644 --- a/doc/about/index.md +++ b/doc/about/index.md @@ -15,5 +15,6 @@ If you like Panel and have built something you want to share, tweet a link or sc :maxdepth: 2 releases +people roadmap ``` diff --git a/doc/about/people.md b/doc/about/people.md new file mode 100644 index 0000000000..6a897fb9ee --- /dev/null +++ b/doc/about/people.md @@ -0,0 +1,23 @@ +# People + +## Project Lead + +Philipp Rudiger (@philippfr) + +## Maintainers + +- Philipp Rudiger (@philippfr) +- Simon Hansen (@Hoxbro) +- Maxime Liquet (@maximlt) +- Marc Skov Madsen (@MarcSkovMadsen) +- Andrew Huang (@ahuang11) + +## Steering Committee + +The Panel project is governed by the [HoloViz steering committee](https://github.com/holoviz/holoviz/blob/main/doc/governance/org-docs/STEERING-COMMITTEE.md). + +## CoC Subcommittee + +- James A. Bednar (@jbednar) +- Sophia Yang (@sophiamyang) +- Philipp Rudiger (@philippjfr) diff --git a/doc/conf.py b/doc/conf.py index 2b3f2f4a09..185ebc643b 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -45,7 +45,7 @@ current_release = panel.__version__ # Current release version variable -announcement_text = f"Panel {current_release} has just been released! Check out the release notes and support Panel by giving it a 🌟 on Github." +announcement_text = f"Panel {current_release} has just been released! Check out the release notes and support Panel by giving it a 🌟 on Github." html_theme_options = { @@ -124,10 +124,10 @@ 'layouts', # 3 most important by expected usage. Rest alphabetically 'chat', - 'custom_components', 'global', 'indicators', 'templates', + 'custom_components', ], 'titles': { 'Vega': 'Altair & Vega', @@ -167,10 +167,16 @@ def get_requirements(): requirements[src] = deps return requirements + +html_js_files = [ + (None, {'body': '{"shimMode": true}', 'type': 'esms-options'}), + f'https://cdn.holoviz.org/panel/{js_version}/dist/bundled/reactiveesm/es-module-shims@^1.10.0/dist/es-module-shims.min.js' +] + nbsite_pyodide_conf = { 'PYODIDE_URL': f'https://cdn.jsdelivr.net/pyodide/{PYODIDE_VERSION}/full/pyodide.js', 'requirements': [bokeh_req, panel_req, 'pyodide-http'], - 'requires': get_requirements() + 'requires': get_requirements(), } templates_path += [ @@ -236,12 +242,15 @@ def _get_pyodide_version(): raise NotImplementedError(F"{PYODIDE_VERSION=} is not valid") def update_versions(app, docname, source): + from panel.models.tabulator import TABULATOR_VERSION + # Inspired by: https://stackoverflow.com/questions/8821511 version_replace = { "{{PANEL_VERSION}}" : PY_VERSION, "{{BOKEH_VERSION}}" : BOKEH_VERSION, "{{PYSCRIPT_VERSION}}" : PYSCRIPT_VERSION, "{{PYODIDE_VERSION}}" : _get_pyodide_version(), + "{{TABULATOR_VERSION}}" : TABULATOR_VERSION, } for old, new in version_replace.items(): diff --git a/doc/explanation/api/reactivity.md b/doc/explanation/api/reactivity.md index de6fea9b13..a838a9545b 100644 --- a/doc/explanation/api/reactivity.md +++ b/doc/explanation/api/reactivity.md @@ -46,7 +46,7 @@ def submit_form(event): user.param.watch(update_preview, 'value') age.param.watch(update_preview, 'value') -submit.onclick(submit_form) +submit.on_click(submit_form) pn.Row(widgets, md) ``` diff --git a/doc/how_to/concurrency/manual_threading.md b/doc/how_to/concurrency/manual_threading.md index 25c85d5804..39dadc6fbc 100644 --- a/doc/how_to/concurrency/manual_threading.md +++ b/doc/how_to/concurrency/manual_threading.md @@ -87,7 +87,7 @@ class SessionTaskRunner(pn.viewable.Viewer): def __panel__(self): return pn.Column( - f"## TaskRunner {id(self)}", + f"## Session TaskRunner {id(self)}", pn.pane.Str(self.param.status), pn.pane.Str(pn.rx("Last Result: {value}").format(value=self.param.value)), ) @@ -118,6 +118,13 @@ button = pn.widgets.Button(name="Add Task", on_click=add_task, button_type="prim pn.Column(button, task_runner).servable() ``` +The application should look like: + + + Since processing occurs on a separate thread, the application remains responsive to further user interactions, such as queuing new tasks. :::{note} @@ -231,7 +238,7 @@ class GlobalTaskRunner(pn.viewable.Viewer): def __panel__(self): return pn.Column( - f"## TaskRunner {id(self)}", + f"## Global TaskRunner {id(self)}", self.param.seconds, pn.pane.Str(pn.rx("Last Result: {value}").format(value=self.param.value)), pn.pane.Str( @@ -263,6 +270,13 @@ pn.Column( ).servable() ``` +The application should look like: + + + :::{note} For efficient use of global threading: diff --git a/doc/how_to/custom_components/esm/build.md b/doc/how_to/custom_components/esm/build.md new file mode 100644 index 0000000000..c895c03f96 --- /dev/null +++ b/doc/how_to/custom_components/esm/build.md @@ -0,0 +1,161 @@ +# Handling of external resources + +The ESM components make it possible to load external libraries from NPM or GitHub easily using one of two approaches: + +1. Directly importing from `esm.sh` or another CDN or by defining a so called importmap. +2. Bundling the resources using `npm` and `esbuild`. + +In this guide we will cover how and when to use each of these approaches. + +## Imports + +So called [ECMA script modules](https://en.wikipedia.org/wiki/ECMAScript#6th_Edition_%E2%80%93_ECMAScript_2015) or ESM modules for short, made it much simpler to build reusable modules that could easily import other libraries. Specifically they introduced `import` and `export` specifiers, which allow developers to import other libraries and export specific functions, objects and classes for the consumption of others. + +These imports can reference modules directly on some CDN or you can define a so called [`importmap`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap), which allows you to specify where to load a library from. Let's start with a simple example, we are going to build a `ConfettiButton`. + +### Inline Imports + +Let us first specify the Python portion of our component, we are simply going to create a `JSComponent` that loads `confetti.js`: + +```python +import panel as pn + +from panel.custom import JSComponent + +pn.extension() + +class ConfettiButton(JSComponent): + + _esm = 'confetti.js' + +ConfettiButton().servable() +``` + +Now that we have our Python component let's build the Javascript (or TypeScript if you like): + +```javascript +/* confetti.js */ +import confetti from "https://esm.sh/canvas-confetti@1.6.0"; + +export function render() { + const button = document.createElement('button') + button.addEventListener('click', () => confetti()) + button.append('Click me!') + return button +} +``` + +Here we are importing the library directly from [esm.sh](https://esm.sh/), a fast and reliable CDN to fetch libraries compiled as modern ESM bundles from. + +:::{note} +esm.sh is very powerful and has many options for specifying shared dependencies or bundling dependencies together. Make sure to [check out the docs](https://esm.sh/#docs). +::: + +### Import Maps + +Once you move past initial development we recommend making use of import maps. To quote MDN: + +> An import map is a JSON object that allows developers to control how the browser resolves module specifiers when importing JavaScript modules. It provides a mapping between the text used as the module specifier in an import statement or import() operator, and the corresponding value that will replace the text when resolving the specifier. + +The import map can be declared directly on the `JSComponent` using the `_importmap` attribute. A minimum it must contain some imports: + +```python +class ConfettiButton(JSComponent): + + _importmap = { + "imports": { + "canvas-confetti": "https://esm.sh/canvas-confetti@1.6.0", + } + } + + _esm = 'confetti.js' +``` + +Now that we have separately declared the import we can update the `import` line in the `confetti.js` file: + +```javascript +/* confetti.js */ +import confetti from "canvas-confetti"; +``` + +This approach cleanly separates the definitions of the libraries and their versions from the actual code. Import maps have a bunch of other features but in most cases the imports section will be all you need. + +## Bundling + +Importing libraries directly from a CDN allows for extremely quick iteration but also means that the users of your components will have to have access to the internet to fetch the required modules. By bundling the component resources you can ship a self-contained module that includes all the dependencies, while also ensuring that you only fetch the parts of the libraries that are actually needed. + +### Tooling + +The tooling we recommend to bundle your component resources include `esbuild` and `npm`, both can conveniently be installed with `conda`: + +```bash +conda install esbuild npm +``` + +### Configuration + +To run the bundling we will need one additional file, the `package.json`, which, just like the import maps, determines the required packages and their versions. The `package.json` is a complex file with tons of configuration options but all we will need are the [dependencies](https://docs.npmjs.com/cli/v10/configuring-npm/package-json#dependencies). + +To recap here are the three files that we need: + +::::{tab-set} + +:::tab-item package.json +```json +{ + "name": "confetti-button", + "dependencies": { + "canvas-confetti": "^1.6.0" + } +} +``` +::: + +:::{tab-item} confetti.py +import panel as pn + +from panel.custom import JSComponent + +pn.extension() + +class ConfettiButton(JSComponent): + + _esm = 'confetti.bundled.js' + +ConfettiButton().servable() +::: + +:::{tab-item} confetti.js +import confetti from "canvas-confetti"; + +export function render() { + const button = document.createElement('button') + button.addEventListener('click', () => confetti()) + button.append('Click me!') + return button +} +::: + +:::: + +Once you have set up these three files you have to install the packages with `npm`: + +```bash +npm install +``` + +This will fetch the packages and install them into the local `node_modules` directory. Once that is complete we can run the bundling: + +```bash +esbuild confetti.js --bundle --format=esm --minify --outfile=confetti.bundled.js +``` + +This will create a new file called `confetti.bundled.js`, which includes all the dependencies (even CSS, image files and other static assets if you have imported them). + +The only thing left to do now is to update the `_esm` declaration to point to the new bundled file: + +```python +class ConfettiButton(JSComponent): + + _esm = 'confetti.bundled.js' +``` diff --git a/doc/how_to/custom_components/index.md b/doc/how_to/custom_components/index.md index 9de4359823..9c284a099d 100644 --- a/doc/how_to/custom_components/index.md +++ b/doc/how_to/custom_components/index.md @@ -58,6 +58,14 @@ Build custom components in Javascript using so called ESM components, which allo ::::{grid} 1 2 2 3 :gutter: 1 1 1 2 + +:::{grid-item-card} {octicon}`tools;2.5em;sd-mr-1 sd-animate-grow50` Building and Bundling ESM components +:link: esm/build +:link-type: doc + +How to specify and bundle external dependencies for ESM components. +::: + :::{grid-item-card} {octicon}`pencil;2.5em;sd-mr-1 sd-animate-grow50` Add callbacks to ESM components :link: esm/callbacks :link-type: doc @@ -124,6 +132,7 @@ Build custom components wrapping Material UI using `ReactComponent`. :hidden: :maxdepth: 2 +esm/build esm/callbacks esm/custom_widgets esm/custom_layout diff --git a/doc/how_to/wasm/convert.md b/doc/how_to/wasm/convert.md index f34beb3cc5..cbe1807cea 100644 --- a/doc/how_to/wasm/convert.md +++ b/doc/how_to/wasm/convert.md @@ -27,6 +27,7 @@ The ``panel convert`` command has the following options: This example will demonstrate how to *convert* and *serve* a basic data app locally. +- install the dependencies `pip install panel scikit-learn xgboost`. - Create a `script.py` file with the following content ```python @@ -88,7 +89,8 @@ Using the `--to` argument on the CLI you can control the format of the file that - **`pyodide`** (default): Run application using Pyodide running in the main thread. This option is less performant than pyodide-worker but produces completely standalone HTML files that do not have to be hosted on a static file server (e.g. Github Pages). - **`pyodide-worker`**: Generates an HTML file and a JS file containing a Web Worker that runs in a separate thread. This is the most performant option, but files have to be hosted on a static file server. -- **`pyscript`**: Generates an HTML leveraging PyScript. This produces standalone HTML files containing `` and `` tags containing the dependencies and the application code. This output is the most readable, and should have equivalent performance to the `pyodide` option. +- **`pyscript`**: Generates an HTML leveraging PyScript. This produces standalone HTML files containing ` @@ -44,7 +46,7 @@ To get started with Pyodide simply follow their [Getting started guide](https:// return f'Amplitude is: {new}' pn.Row(slider, pn.bind(callback, slider)).servable(target='simple_app'); - `); + `); } main(); @@ -64,44 +66,30 @@ const bk_whl = "https://cdn.holoviz.org/panel/{{PANEL_VERSION}}/dist/wheels/boke const pn_whl = "https://cdn.holoviz.org/panel/{{PANEL_VERSION}}/dist/wheels/panel-{{PANEL_VERSION}}-py3-none-any.whl" await micropip.install(bk_whl, pn_whl) ``` + ::: ### PyScript -PyScript makes it even easier to manage your dependencies, with a `` HTML tag. Simply include `panel` in the list of dependencies and PyScript will install it automatically: - -```html - -packages = [ - "panel", - ... -] - -``` - -Once installed you will be able to `import panel` in your `` tag. Again, make sure you also load Bokeh.js and Panel.js: +A basic, single file pyscript example looks like ```html + + - - + + - - packages = [ - "https://cdn.holoviz.org/panel/{{PANEL_VERSION}}/dist/wheels/bokeh-{{BOKEH_VERSION}}-py3-none-any.whl", - "https://cdn.holoviz.org/panel/{{PANEL_VERSION}}/dist/wheels/panel-{{PANEL_VERSION}}-py3-none-any.whl" - ] -
- + ``` -The app should look identical to the one above but show a loading spinner while Pyodide is initializing. +The app should look identical to the one above. + +The [PyScript](https://docs.pyscript.net) documentation recommends you put your configuration and python code into separate files. You can find such examples in the [PyScript Examples Gallery](https://pyscript.com/@examples?q=panel). ## Rendering Panel components in Pyodide or Pyscript diff --git a/doc/tutorials/basic/build_crossfilter_dashboard.md b/doc/tutorials/basic/build_crossfilter_dashboard.md index 0ac004688a..915bb0e4fe 100644 --- a/doc/tutorials/basic/build_crossfilter_dashboard.md +++ b/doc/tutorials/basic/build_crossfilter_dashboard.md @@ -19,7 +19,7 @@ holoviews panel :::::{dropdown} Code -```{pyodide} +```python import holoviews as hv import numpy as np import pandas as pd @@ -80,7 +80,7 @@ def get_plots(): active_tools=["box_select"], ) - return (plot_by_year + plot_by_manufacturer).opts(shared_axes=False).cols(1) + return (plot_by_year + plot_by_manufacturer).cols(1) crossfilter_plots = hv.link_selections(get_plots()).opts(shared_axes=False) @@ -149,6 +149,8 @@ def get_plots(): # Define shared dataset ds = hv.Dataset(data, ["t_manu", "p_year", "t_cap"], "t_cap") + + ... # continues in next section ``` The function starts by calling `get_data()` to retrieve the preprocessed dataset. It then uses HoloViews to define a dataset (`ds`) that encapsulates our data, specifying columns for manufacturers (`t_manu`), production year (`p_year`), and turbine capacity (`t_cap`). @@ -164,10 +166,9 @@ In order for [HoloViews linked brushing](https://holoviews.org/user_guide/Linked Next, we aggregate the data by year and manufacturer to create two separate plots. The plots are formatted to be responsive and use an accent color for consistency. ```python - @pn.cache def get_plots(): - ... + ... # continues from previous section # Create plots ds_by_year = ds.aggregate("p_year", function=np.sum).sort("p_year")[1995:] @@ -228,7 +229,7 @@ pn.template.FastListTemplate( main=[crossfilter_plots], main_layout=None, accent=ACCENT, -) +).servable() ``` The [`FastListTemplate`](https://panel.holoviz.org/reference/templates/FastListTemplate.html) is a pre-built Panel template that provides a clean and modern layout for our dashboard. It takes our crossfiltering plot and other configurations as input, creating a cohesive and interactive web application. diff --git a/doc/tutorials/intermediate/build_server_video_stream.md b/doc/tutorials/intermediate/build_server_video_stream.md index 16037aacc1..daa9a5b9b4 100644 --- a/doc/tutorials/intermediate/build_server_video_stream.md +++ b/doc/tutorials/intermediate/build_server_video_stream.md @@ -2,9 +2,12 @@ Welcome to our tutorial on building a **server-side video camera application** using HoloViz Panel! In this fun and engaging guide, we'll walk you through the process of setting up a video stream from a camera connected to a server, not the user's machine. This approach uses Python's threading to handle real-time video processing without freezing the user interface. -Let's dive into the code and see how it all comes together. + -:::{drowdown} Code +:::{dropdown} Code `server_video_stream.py` @@ -127,6 +130,8 @@ server_video_stream.servable() ::: +Let's dive into the code and see how it all comes together. + ## Install the Dependencies To run the application, you'll need several packages: @@ -143,7 +148,7 @@ You can install these using conda or pip: :sync: conda ``` bash -conda install -y -c conda-forge opencv panel pillow +conda install -y -c conda-forge opencv panel pillow watchfiles ``` ::: @@ -152,7 +157,7 @@ conda install -y -c conda-forge opencv panel pillow :sync: pip ``` bash -pip install opencv panel pillow +pip install opencv-python panel pillow watchfiles ``` ::: @@ -342,6 +347,13 @@ Try serving the app with panel serve app.py ``` +It should look like: + + + ## References ### How-To Guides diff --git a/examples/how_to/custom/react/material_ui.py b/examples/how_to/custom/react/material_ui.py index 262ac7ee29..a63ea4eef6 100644 --- a/examples/how_to/custom/react/material_ui.py +++ b/examples/how_to/custom/react/material_ui.py @@ -1,5 +1,3 @@ -import pathlib - import param import panel as pn diff --git a/examples/reference/chat/ChatFeed.ipynb b/examples/reference/chat/ChatFeed.ipynb index 3866e7f8d5..bf117aeb7c 100644 --- a/examples/reference/chat/ChatFeed.ipynb +++ b/examples/reference/chat/ChatFeed.ipynb @@ -263,7 +263,7 @@ " return f\"Echoing {user!r}... {contents}\\n\\n{instance!r}\"\n", "\n", "chat_feed = pn.chat.ChatFeed(callback=echo_message)\n", - "chat_feed.show()" + "chat_feed" ] }, { @@ -731,6 +731,123 @@ "See [`ChatStep`](ChatStep.ipynb) for more details on how to use those components." ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Prompt User\n", + "\n", + "It is possible to temporarily pause the execution of code and prompt the user to answer a question, or fill out a form, using `prompt_user`, which accepts any Panel `component` and a follow-up `callback` (with `component` and `instance` as args) to execute upon submission." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def repeat_answer(component, instance):\n", + " contents = component.value\n", + " instance.send(f\"Wow, {contents}, that's my favorite flavor too!\", respond=False, user=\"Ice Cream Bot\")\n", + "\n", + "\n", + "def show_interest(contents, user, instance):\n", + " if \"ice\" in contents or \"cream\" in contents:\n", + " answer_input = pn.widgets.TextInput(\n", + " placeholder=\"Enter your favorite ice cream flavor\"\n", + " )\n", + " instance.prompt_user(answer_input, callback=repeat_answer)\n", + " else:\n", + " return \"I'm not interested in that topic.\"\n", + "\n", + "\n", + "chat_feed = pn.chat.ChatFeed(\n", + " callback=show_interest,\n", + " callback_user=\"Ice Cream Bot\",\n", + ")\n", + "chat_feed" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_feed.send(\"food\");" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can also set a `predicate` to evaluate the component's state, e.g. widget has value. If provided, the submit button will be enabled when the predicate returns `True`. The `predicate` should accept the component as an argument." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def is_chocolate(component):\n", + " return \"chocolate\" in component.value.lower()\n", + "\n", + "\n", + "def repeat_answer(component, instance):\n", + " contents = component.value\n", + " instance.send(f\"Wow, {contents}, that's my favorite flavor too!\", respond=False, user=\"Ice Cream Bot\")\n", + "\n", + "\n", + "def show_interest(contents, user, instance):\n", + " if \"ice\" in contents or \"cream\" in contents:\n", + " answer_input = pn.widgets.TextInput(\n", + " placeholder=\"Enter your favorite ice cream flavor\"\n", + " )\n", + " instance.prompt_user(answer_input, callback=repeat_answer, predicate=is_chocolate)\n", + " else:\n", + " return \"I'm not interested in that topic.\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can also set a `timeout` in seconds and `timeout_message` to prevent submission after a certain time." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def is_chocolate(component):\n", + " return \"chocolate\" in component.value.lower()\n", + "\n", + "\n", + "def repeat_answer(component, instance):\n", + " contents = component.value\n", + " instance.send(f\"Wow, {contents}, that's my favorite flavor too!\", respond=False, user=\"Ice Cream Bot\")\n", + "\n", + "\n", + "def show_interest(contents, user, instance):\n", + " if \"ice\" in contents or \"cream\" in contents:\n", + " answer_input = pn.widgets.TextInput(\n", + " placeholder=\"Enter your favorite ice cream flavor\"\n", + " )\n", + " instance.prompt_user(answer_input, callback=repeat_answer, predicate=is_chocolate, timeout=10, timeout_message=\"You're too slow!\")\n", + " else:\n", + " return \"I'm not interested in that topic.\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Lastly, use `button_params` and `timeout_button_params` to customize the appearance of the buttons and timeout button, respectively." + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/examples/reference/chat/ChatMessage.ipynb b/examples/reference/chat/ChatMessage.ipynb index 211fcdbf68..2bfeb78814 100644 --- a/examples/reference/chat/ChatMessage.ipynb +++ b/examples/reference/chat/ChatMessage.ipynb @@ -399,12 +399,18 @@ " background-color: red;\n", " border-radius: 5%;\n", " }\n", + " .meta {\n", + " background-color: lightgreen;\n", + " }\n", " .header {\n", " background-color: green;\n", " }\n", " .footer {\n", " background-color: blue;\n", " }\n", + " .icons {\n", + " background-color: lightblue;\n", + " }\n", " .name {\n", " background-color: orange;\n", " }\n", @@ -431,10 +437,12 @@ " }\n", "\"\"\"\n", "\n", - "ChatMessage(\n", + "pn.chat.ChatMessage(\n", " \"Style me up!\",\n", " show_activity_dot=True,\n", " stylesheets=[path_to_stylesheet],\n", + " footer_objects=[pn.widgets.Button(name=\"Reply\", button_type=\"primary\")],\n", + " header_objects=[pn.widgets.TextInput(placeholder=\"Name\")],\n", ")" ] }, diff --git a/examples/reference/custom_components/AnyWidget.md b/examples/reference/custom_components/AnyWidget.md deleted file mode 100644 index 3fdd2012f1..0000000000 --- a/examples/reference/custom_components/AnyWidget.md +++ /dev/null @@ -1,310 +0,0 @@ -# `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/AnyWidgetComponent.ipynb b/examples/reference/custom_components/AnyWidgetComponent.ipynb new file mode 100644 index 0000000000..3e410dda70 --- /dev/null +++ b/examples/reference/custom_components/AnyWidgetComponent.ipynb @@ -0,0 +1,394 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "031c5d57-b722-4999-88ac-686ac83d3ef1", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "\n", + "pn.extension()" + ] + }, + { + "cell_type": "markdown", + "id": "9908a714-692d-4513-aca8-b251a627cae4", + "metadata": {}, + "source": [ + "Panel's `AnyWidgetComponent` class simplifies the creation of custom Panel components using the [`AnyWidget`](https://anywidget.dev/) JavaScript API." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "93ab8716-5052-4a89-83b4-dd78576816ce", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "import param\n", + "\n", + "from panel.custom import AnyWidgetComponent\n", + "\n", + "\n", + "class CounterButton(AnyWidgetComponent):\n", + "\n", + " value = param.Integer()\n", + "\n", + " _esm = \"\"\"\n", + " function render({ model, el }) {\n", + " let count = () => model.get(\"value\");\n", + " let btn = document.createElement(\"button\");\n", + " btn.innerHTML = `count is ${count()}`;\n", + " btn.addEventListener(\"click\", () => {\n", + " model.set(\"value\", count() + 1);\n", + " model.save_changes();\n", + " });\n", + " model.on(\"change:value\", () => {\n", + " btn.innerHTML = `count is ${count()}`;\n", + " });\n", + " el.appendChild(btn);\n", + " }\n", + " export default { render };\n", + " \"\"\"\n", + "\n", + "CounterButton().servable()" + ] + }, + { + "cell_type": "markdown", + "id": "1a37ed44-8c89-40d6-9c01-b22c5a4c4d0a", + "metadata": {}, + "source": [ + ":::{note}\n", + "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.\n", + "\n", + "If you are looking to create custom components using Python and Panel component only, check out [`Viewer`](Viewer.md).\n", + ":::\n", + "\n", + "\n", + "## API\n", + "\n", + "### AnyWidgetComponent Attributes\n", + "\n", + "- **`_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.\n", + "- **`_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.\n", + "- **`_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.\n", + "\n", + ":::note\n", + "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.\n", + ":::\n", + "\n", + "#### `render` Function\n", + "\n", + "The `_esm` `default` object must contain a `render` function. It accepts the following parameters:\n", + "\n", + "- **`model`**: Represents the parameters of the component and provides methods to `.get` values, `.set` values, and `.save_changes`.\n", + "- **`el`**: The parent HTML element to append HTML elements to.\n", + "\n", + "For more detail, see [`AnyWidget`](https://anywidget.dev/).\n", + "\n", + "## Usage\n", + "\n", + "### Styling with CSS\n", + "\n", + "Include CSS within the `_stylesheets` attribute to style the component. The CSS is injected directly into the component's HTML." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16d63729-efec-4033-8c3e-12295b3910e6", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "import param\n", + "\n", + "from panel.custom import AnyWidgetComponent\n", + "\n", + "\n", + "class StyledCounterButton(AnyWidgetComponent):\n", + "\n", + " value = param.Integer()\n", + "\n", + " _esm = \"\"\"\n", + " function render({ model, el }) {\n", + " let count = () => model.get(\"value\");\n", + " let btn = document.createElement(\"button\");\n", + " btn.innerHTML = `count is ${count()}`;\n", + " btn.addEventListener(\"click\", () => {\n", + " model.set(\"value\", count() + 1);\n", + " model.save_changes();\n", + " });\n", + " model.on(\"change:value\", () => {\n", + " btn.innerHTML = `count is ${count()}`;\n", + " });\n", + " el.appendChild(btn);\n", + " }\n", + " export default { render };\n", + " \"\"\"\n", + "\n", + " _stylesheets = [\n", + " \"\"\"\n", + " button {\n", + " background: #0072B5;\n", + " color: white;\n", + " border: none;\n", + " padding: 10px;\n", + " border-radius: 4px;\n", + " }\n", + " button:hover {\n", + " background: #4099da;\n", + " }\n", + " \"\"\"\n", + " ]\n", + "\n", + "StyledCounterButton().servable()" + ] + }, + { + "cell_type": "markdown", + "id": "7619dad4-4dfe-43a1-aac1-32c57ddffc58", + "metadata": {}, + "source": [ + "### Dependency Imports\n", + "\n", + "JavaScript dependencies can be directly imported via URLs, such as those from [`esm.sh`](https://esm.sh/)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e5bd2900-61d6-4112-8522-6a0239bf6d1f", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "\n", + "from panel.custom import AnyWidgetComponent\n", + "\n", + "\n", + "class ConfettiButton(AnyWidgetComponent):\n", + "\n", + " _esm = \"\"\"\n", + " import confetti from \"https://esm.sh/canvas-confetti@1.6.0\";\n", + "\n", + " function render({ el }) {\n", + " let btn = document.createElement(\"button\");\n", + " btn.innerHTML = \"Click Me\";\n", + " btn.addEventListener(\"click\", () => {\n", + " confetti();\n", + " });\n", + " el.appendChild(btn);\n", + " }\n", + " export default { render }\n", + " \"\"\"\n", + "\n", + "ConfettiButton().servable()\n" + ] + }, + { + "cell_type": "markdown", + "id": "cc5c58de-544d-4cac-b103-b6db1e4dc139", + "metadata": {}, + "source": [ + "Use the `_importmap` attribute for more concise module references." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c20cda5c-7176-4d3d-8b56-01acad7aa924", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "\n", + "from panel.custom import AnyWidgetComponent\n", + "\n", + "\n", + "class ConfettiButton(AnyWidgetComponent):\n", + "\n", + " _importmap = {\n", + " \"imports\": {\n", + " \"canvas-confetti\": \"https://esm.sh/canvas-confetti@1.6.0\",\n", + " }\n", + " }\n", + "\n", + " _esm = \"\"\"\n", + " import confetti from \"canvas-confetti\";\n", + "\n", + " function render({ el }) {\n", + " let btn = document.createElement(\"button\");\n", + " btn.innerHTML = \"Click Me\";\n", + " btn.addEventListener(\"click\", () => {\n", + " confetti();\n", + " });\n", + " el.appendChild(btn);\n", + " }\n", + " export default { render }\n", + " \"\"\"\n", + "\n", + "ConfettiButton().servable()" + ] + }, + { + "cell_type": "markdown", + "id": "c1d8d880-3b55-4eb4-998c-3fb265b47322", + "metadata": {}, + "source": [ + "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.\n", + "\n", + "### External Files\n", + "\n", + "You can load JavaScript and CSS from files by providing the paths to these files.\n", + "\n", + "Create the file **counter_button.py**.\n", + "\n", + "```python\n", + "from pathlib import Path\n", + "\n", + "import param\n", + "import panel as pn\n", + "\n", + "from panel.custom import AnyWidgetComponent\n", + "\n", + "pn.extension()\n", + "\n", + "class CounterButton(AnyWidgetComponent):\n", + "\n", + " value = param.Integer()\n", + "\n", + " _esm = Path(\"counter_button.js\")\n", + " _stylesheets = [Path(\"counter_button.css\")]\n", + "\n", + "CounterButton().servable()\n", + "```\n", + "\n", + "Now create the file **counter_button.js**.\n", + "\n", + "```javascript\n", + "function render({ model, el }) {\n", + " let value = () => model.get(\"value\");\n", + " let btn = document.createElement(\"button\");\n", + " btn.innerHTML = `count is ${value()}`;\n", + " btn.addEventListener(\"click\", () => {\n", + " model.set('value', value() + 1);\n", + " model.save_changes();\n", + " });\n", + " model.on(\"change:value\", () => {\n", + " btn.innerHTML = `count is ${value()}`;\n", + " });\n", + " el.appendChild(btn);\n", + "}\n", + "export default { render }\n", + "```\n", + "\n", + "Now create the file **counter_button.css**.\n", + "\n", + "```css\n", + "button {\n", + " background: #0072B5;\n", + " color: white;\n", + " border: none;\n", + " padding: 10px;\n", + " border-radius: 4px;\n", + "}\n", + "button:hover {\n", + " background: #4099da;\n", + "}\n", + "```\n", + "\n", + "Serve the app with `panel serve counter_button.py --autoreload`.\n", + "\n", + "You can now edit the JavaScript or CSS file, and the changes will be automatically reloaded.\n", + "\n", + "- Try changing the `innerHTML` from `count is ${value()}` to `COUNT IS ${value()}` and observe the update. Note you must update `innerHTML` in two places.\n", + "- Try changing the background color from `#0072B5` to `#008080`.\n", + "\n", + "### React\n", + "\n", + "You can use React with `AnyWidget` as shown below." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1d2b5ea2-18cd-47fa-a639-7535f5c1652d", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "import param\n", + "\n", + "from panel.custom import AnyWidgetComponent\n", + "\n", + "\n", + "class CounterButton(AnyWidgetComponent):\n", + "\n", + " value = param.Integer()\n", + "\n", + " _importmap = {\n", + " \"imports\": {\n", + " \"@anywidget/react\": \"https://esm.sh/@anywidget/react\",\n", + " \"react\": \"https://esm.sh/react\",\n", + " }\n", + " }\n", + "\n", + " _esm = \"\"\"\n", + " import * as React from \"react\"; /* mandatory import */\n", + " import { createRender, useModelState } from \"@anywidget/react\";\n", + "\n", + " const render = createRender(() => {\n", + " const [value, setValue] = useModelState(\"value\");\n", + " return (\n", + " \n", + " );\n", + " });\n", + " export default { render }\n", + " \"\"\"\n", + "\n", + "CounterButton().servable()" + ] + }, + { + "cell_type": "markdown", + "id": "a8e7f361-5df5-4fe0-b81a-a50d6680f0f9", + "metadata": {}, + "source": [ + ":::{note}\n", + "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.\n", + ":::\n", + "\n", + "## References\n", + "\n", + "### Tutorials\n", + "\n", + "- [Build Custom Components](../../../how_to/custom_components/reactive_esm/reactive_esm_layout.md)\n", + "\n", + "### How-To Guides\n", + "\n", + "- [Convert `AnyWidget` widgets](../../../how_to/migrate/anywidget/index.md)\n", + "\n", + "### Reference Guides\n", + "\n", + "- [`AnyWidgetComponent`](./AnyWidgetComponent.ipynb)\n", + "- [`JSComponent`](./JSComponent.ipynb)\n", + "- [`ReactComponent`](./ReactComponent.ipynb)" + ] + } + ], + "metadata": { + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/reference/custom_components/JSComponent.ipynb b/examples/reference/custom_components/JSComponent.ipynb new file mode 100644 index 0000000000..fc87414d43 --- /dev/null +++ b/examples/reference/custom_components/JSComponent.ipynb @@ -0,0 +1,594 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "e888ffe5-e558-4ae2-b7c0-b52804eb8ce2", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "\n", + "pn.extension()" + ] + }, + { + "cell_type": "markdown", + "id": "34c90e0e-c932-4346-8452-72f9c3aeecbb", + "metadata": {}, + "source": [ + "`JSComponent` simplifies the creation of custom Panel components using JavaScript." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f7e59ce8-e218-41b9-bb9c-4c5685ed5b44", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "import param\n", + "\n", + "from panel.custom import JSComponent\n", + "\n", + "\n", + "class CounterButton(JSComponent):\n", + "\n", + " value = param.Integer()\n", + "\n", + " _esm = \"\"\"\n", + " export function render({ model }) {\n", + " let btn = document.createElement(\"button\");\n", + " btn.innerHTML = `count is ${model.value}`;\n", + " btn.addEventListener(\"click\", () => {\n", + " model.value += 1\n", + " });\n", + " model.on('value', () => {\n", + " btn.innerHTML = `count is ${model.value}`;\n", + " })\n", + " return btn\n", + " }\n", + " \"\"\"\n", + "\n", + "CounterButton().servable()" + ] + }, + { + "cell_type": "markdown", + "id": "188e2bf7-1bf0-4a61-a867-7770dd54eb46", + "metadata": {}, + "source": [ + ":::{note}\n", + "`JSComponent` was introduced in June 2024 as a successor to `ReactiveHTML`.\n", + "\n", + "`JSComponent` bears similarities to [`AnyWidget`](https://anywidget.dev/), but it is specifically optimized for use with Panel.\n", + "\n", + "If you are looking to create custom components using Python and Panel component only, check out [`Viewer`](Viewer.md).\n", + ":::\n", + "\n", + "## API\n", + "\n", + "### JSComponent Attributes\n", + "\n", + "- **`_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.\n", + "- **`_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.\n", + "- **`_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.\n", + "\n", + ":::note\n", + "\n", + "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.\n", + "\n", + ":::\n", + "\n", + "### `render` Function\n", + "\n", + "The `_esm` attribute must export the `render` function. It accepts the following parameters:\n", + "\n", + "- **`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.\n", + "- **`view`**: The Bokeh view.\n", + "- **`el`**: The HTML element that the component will be rendered into.\n", + "\n", + "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.\n", + "\n", + "### Callbacks\n", + "\n", + "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.\n", + "\n", + "#### Change Events\n", + "\n", + "The following signatures are valid when listening to change events:\n", + "\n", + "- `.on('', callback)`: Allows registering an event handler for a single parameter.\n", + "- `.on(['', ...], callback)`: Allows adding an event handler for multiple parameters at once.\n", + "- `.on('change:', callback)`: The `change:` prefix allows disambiguating change events from lifecycle hooks should a parameter name and lifecycle hook overlap.\n", + "\n", + "#### Lifecycle Hooks\n", + "\n", + "- `after_render`: Called once after the component has been fully rendered.\n", + "- `after_resize`: Called after the component has been resized.\n", + "- `remove`: Called when the component view is being removed from the DOM.\n", + "\n", + "## Usage\n", + "\n", + "### Styling with CSS\n", + "\n", + "Include CSS within the `_stylesheets` attribute to style the component. The CSS is injected directly into the component's HTML." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0f0ca9f3-547e-4378-8cc8-f745b987b121", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "import param\n", + "\n", + "from panel.custom import JSComponent\n", + "\n", + "\n", + "class StyledCounterButton(JSComponent):\n", + "\n", + " value = param.Integer()\n", + "\n", + " _stylesheets = [\n", + " \"\"\"\n", + " button {\n", + " background: #0072B5;\n", + " color: white;\n", + " border: none;\n", + " padding: 10px;\n", + " border-radius: 4px;\n", + " }\n", + " button:hover {\n", + " background: #4099da;\n", + " }\n", + " \"\"\"\n", + " ]\n", + "\n", + " _esm = \"\"\"\n", + " export function render({ model }) {\n", + " const btn = document.createElement(\"button\");\n", + " btn.innerHTML = `count is ${model.value}`;\n", + " btn.addEventListener(\"click\", () => {\n", + " model.value += 1\n", + " });\n", + " model.on('value', () => {\n", + " btn.innerHTML = `count is ${model.value}`;\n", + " })\n", + " return btn\n", + " }\n", + " \"\"\"\n", + "\n", + "StyledCounterButton().servable()" + ] + }, + { + "cell_type": "markdown", + "id": "1d16400f-d238-48c6-aeca-73d65ed62027", + "metadata": {}, + "source": [ + "## Send Events from JavaScript to Python\n", + "\n", + "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)`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a21ae67f-2077-4bf8-9184-b02bf84e4326", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "import param\n", + "\n", + "from panel.custom import JSComponent\n", + "\n", + "\n", + "class EventExample(JSComponent):\n", + "\n", + " value = param.Parameter()\n", + "\n", + " _esm = \"\"\"\n", + " export function render({ model }) {\n", + " const btn = document.createElement('button')\n", + " btn.innerHTML = `Click Me`\n", + " btn.onclick = (event) => model.send_event('click', event)\n", + " return btn\n", + " }\n", + " \"\"\"\n", + "\n", + " def _handle_click(self, event):\n", + " self.value = str(event.__dict__)\n", + "\n", + "button = EventExample()\n", + "pn.Column(\n", + " button, pn.widgets.TextAreaInput(value=button.param.value, height=200),\n", + ").servable()\n" + ] + }, + { + "cell_type": "markdown", + "id": "4979e5b0-7caa-4ffd-a8bd-40680e048935", + "metadata": {}, + "source": [ + "You can also define and send your own custom events:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f99cc078-3b4f-43d8-85aa-983263f1a800", + "metadata": {}, + "outputs": [], + "source": [ + "import datetime\n", + "\n", + "import panel as pn\n", + "import param\n", + "\n", + "from panel.custom import JSComponent\n", + "\n", + "\n", + "class CustomEventExample(JSComponent):\n", + "\n", + " value = param.String()\n", + "\n", + " _esm = \"\"\"\n", + " export function render({ model }) {\n", + " const btn = document.createElement('button')\n", + " btn.innerHTML = `Click Me`;\n", + " btn.onclick = (event) => {\n", + " const currentDate = new Date();\n", + " const custom_event = new CustomEvent(\"click\", { detail: currentDate.getTime() });\n", + " model.send_event('click', custom_event)\n", + " }\n", + " return btn\n", + " }\n", + " \"\"\"\n", + "\n", + " def _handle_click(self, event):\n", + " unix_timestamp = event.data[\"detail\"]/1000\n", + " python_datetime = datetime.datetime.fromtimestamp(unix_timestamp)\n", + " self.value = str(python_datetime)\n", + "\n", + "button = CustomEventExample()\n", + "pn.Column(\n", + " button, button.param.value,\n", + ").servable()\n" + ] + }, + { + "cell_type": "markdown", + "id": "d4b72890-3512-42ad-9352-30c89b7e9c52", + "metadata": {}, + "source": [ + "## Dependency Imports\n", + "\n", + "JavaScript dependencies can be directly imported via URLs, such as those from [`esm.sh`](https://esm.sh/)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0a617ff6-a168-4bc2-8348-304e3d85c70d", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "\n", + "from panel.custom import JSComponent\n", + "\n", + "\n", + "class ConfettiButton(JSComponent):\n", + "\n", + " _esm = \"\"\"\n", + " import confetti from \"https://esm.sh/canvas-confetti@1.6.0\";\n", + "\n", + " export function render() {\n", + " let btn = document.createElement(\"button\");\n", + " btn.innerHTML = \"Click Me\";\n", + " btn.addEventListener(\"click\", () => {\n", + " confetti()\n", + " });\n", + " return btn\n", + " }\n", + " \"\"\"\n", + "\n", + "ConfettiButton().servable()" + ] + }, + { + "cell_type": "markdown", + "id": "8dd08bad-a752-4102-ac7d-a09c4b96991d", + "metadata": {}, + "source": [ + "Use the `_importmap` attribute for more concise module references." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "936b653c-637e-4ecd-bc57-324a007c8802", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "\n", + "from panel.custom import JSComponent\n", + "\n", + "\n", + "class ConfettiButton(JSComponent):\n", + " _importmap = {\n", + " \"imports\": {\n", + " \"canvas-confetti\": \"https://esm.sh/canvas-confetti@1.6.0\",\n", + " }\n", + " }\n", + "\n", + " _esm = \"\"\"\n", + " import confetti from \"canvas-confetti\";\n", + "\n", + " export function render() {\n", + " let btn = document.createElement(\"button\");\n", + " btn.innerHTML = `Click Me`;\n", + " btn.addEventListener(\"click\", () => {\n", + " confetti()\n", + " });\n", + " return btn\n", + " }\n", + " \"\"\"\n", + "\n", + "ConfettiButton().servable()" + ] + }, + { + "cell_type": "markdown", + "id": "80a29850-659f-4254-ac94-0a2b78e9e541", + "metadata": {}, + "source": [ + "See [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) for more info about the import map format." + ] + }, + { + "cell_type": "markdown", + "id": "c890e2e9-9027-4a52-9d21-65fd135b8c38", + "metadata": {}, + "source": [ + "## External Files\n", + "\n", + "You can load JavaScript and CSS from files by providing the paths to these files.\n", + "\n", + "Create the file **counter_button.py**.\n", + "\n", + "```python\n", + "from pathlib import Path\n", + "\n", + "import param\n", + "import panel as pn\n", + "\n", + "from panel.custom import JSComponent\n", + "\n", + "pn.extension()\n", + "\n", + "class CounterButton(JSComponent):\n", + "\n", + " value = param.Integer()\n", + "\n", + " _esm = Path(\"counter_button.js\")\n", + " _stylesheets = [Path(\"counter_button.css\")]\n", + "\n", + "CounterButton().servable()\n", + "```\n", + "\n", + "Now create the file **counter_button.js**.\n", + "\n", + "```javascript\n", + "export function render({ model }) {\n", + " let btn = document.createElement(\"button\");\n", + " btn.innerHTML = `count is ${model.value}`;\n", + " btn.addEventListener(\"click\", () => {\n", + " model.value += 1;\n", + " });\n", + " model.on('value', () => {\n", + " btn.innerHTML = `count is ${model.value}`;\n", + " });\n", + " return btn;\n", + "}\n", + "```\n", + "\n", + "Now create the file **counter_button.css**.\n", + "\n", + "```css\n", + "button {\n", + " background: #0072B5;\n", + " color: white;\n", + " border: none;\n", + " padding: 10px;\n", + " border-radius: 4px;\n", + "}\n", + "button:hover {\n", + " background: #4099da;\n", + "}\n", + "```\n", + "\n", + "Serve the app with `panel serve counter_button.py --autoreload`.\n", + "\n", + "You can now edit the JavaScript or CSS file, and the changes will be automatically reloaded.\n", + "\n", + "- 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.\n", + "- Try changing the background color from `#0072B5` to `#008080`.\n", + "\n", + "## Displaying A Single Child\n", + "\n", + "You can display Panel components (`Viewable`s) by defining a `Child` parameter.\n", + "\n", + "Lets start with the simplest example:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6810c585-c55a-428b-85ac-ca1363dfd33a", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "\n", + "from panel.custom import Child, JSComponent\n", + "\n", + "class Example(JSComponent):\n", + "\n", + " child = Child()\n", + "\n", + " _esm = \"\"\"\n", + " export function render({ model }) {\n", + " const button = document.createElement(\"button\");\n", + " button.append(model.get_child(\"child\"))\n", + " return button\n", + " }\"\"\"\n", + "\n", + "Example(child=pn.panel(\"A **Markdown** pane!\")).servable()" + ] + }, + { + "cell_type": "markdown", + "id": "e8f2695f-2ced-427c-88d7-414e97e0ec60", + "metadata": {}, + "source": [ + "If you provide a non-`Viewable` child it will automatically be converted to a `Viewable` by `pn.panel`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d796b164-fe14-44da-8795-666e3d4117a2", + "metadata": {}, + "outputs": [], + "source": [ + "Example(child=\"A **Markdown** pane!\").servable()" + ] + }, + { + "cell_type": "markdown", + "id": "c7bf894f-579a-4e19-b7e9-011879db5fc4", + "metadata": {}, + "source": [ + "If you want to allow a certain type of Panel components only you can specify the specific type in the `class_` argument." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "853faef4-c890-4c81-b661-bdba7e89e9df", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "\n", + "from panel.custom import Child, JSComponent\n", + "\n", + "class Example(JSComponent):\n", + "\n", + " child = Child(class_=pn.pane.Markdown)\n", + "\n", + " _esm = \"\"\"\n", + " export function render({ model }) {\n", + " const button = document.createElement(\"button\");\n", + " button.append(model.get_child(\"child\"))\n", + " return button\n", + " }\"\"\"\n", + "\n", + "Example(child=pn.panel(\"A **Markdown** pane!\")).servable()" + ] + }, + { + "cell_type": "markdown", + "id": "d25ec6a5-5d6f-4def-a3f2-aff9fa0a01b3", + "metadata": {}, + "source": [ + "The `class_` argument also supports a tuple of types:\n", + "\n", + "```python\n", + " child = Child(class_=(pn.pane.Markdown, pn.pane.HTML))\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "bc15da31-0841-4166-8e70-bfa9ea97576a", + "metadata": {}, + "source": [ + "## Displaying a List of Children\n", + "\n", + "You can also display a `List` of `Viewable` objects using the `Children` parameter type:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "71f95d2b-e3fd-49de-890c-901cc0a7693e", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "\n", + "from panel.custom import Children, JSComponent\n", + "\n", + "\n", + "class Example(JSComponent):\n", + "\n", + " objects = Children()\n", + "\n", + " _esm = \"\"\"\n", + " export function render({ model }) {\n", + " const div = document.createElement('div')\n", + " div.append(...model.get_child(\"objects\"))\n", + " return div\n", + " }\"\"\"\n", + "\n", + "\n", + "Example(\n", + " objects=[pn.panel(\"A **Markdown** pane!\"), pn.widgets.Button(name=\"Click me!\"), {\"text\": \"I'm shown as a JSON Pane\"}]\n", + ").servable()\n" + ] + }, + { + "cell_type": "markdown", + "id": "d56af9f3-39bd-4c68-bb8e-f889c534326c", + "metadata": {}, + "source": [ + ":::note\n", + "You can change the `item_type` to a specific subtype of `Viewable` or a tuple of\n", + "`Viewable` subtypes.\n", + ":::\n", + "\n", + "## References\n", + "\n", + "### Tutorials\n", + "\n", + "- [Build Custom Components](../../../how_to/custom_components/reactive_esm/reactive_esm_layout.md)\n", + "\n", + "### How-To Guides\n", + "\n", + "- [Convert `AnyWidget` widgets](../../../how_to/migrate/anywidget/index.md)\n", + "\n", + "### Reference Guides\n", + "\n", + "- [`AnyWidgetComponent`](./AnyWidgetComponent.ipynb)\n", + "- [`JSComponent`](./JSComponent.ipynb)\n", + "- [`ReactComponent`](./ReactComponent.ipynb)" + ] + } + ], + "metadata": { + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/reference/custom_components/JSComponent.md b/examples/reference/custom_components/JSComponent.md deleted file mode 100644 index 8914aaf83f..0000000000 --- a/examples/reference/custom_components/JSComponent.md +++ /dev/null @@ -1,456 +0,0 @@ -# `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.ipynb b/examples/reference/custom_components/ReactComponent.ipynb new file mode 100644 index 0000000000..ca5648b2fc --- /dev/null +++ b/examples/reference/custom_components/ReactComponent.ipynb @@ -0,0 +1,632 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "50cd605c-3e41-4909-9214-b0aafd171d25", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "\n", + "pn.extension()" + ] + }, + { + "cell_type": "markdown", + "id": "70eb493f-4216-4d8c-b763-f98b67933cec", + "metadata": {}, + "source": [ + "`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." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ce37ac2a-1fea-4e45-8581-b913e0e05097", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "import param\n", + "\n", + "from panel.custom import ReactComponent\n", + "\n", + "\n", + "class CounterButton(ReactComponent):\n", + "\n", + " value = param.Integer()\n", + "\n", + " _esm = \"\"\"\n", + " export function render({model}) {\n", + " const [value, setValue] = model.useState(\"value\");\n", + " return (\n", + " \n", + " )\n", + " }\n", + " \"\"\"\n", + "\n", + "CounterButton().servable()" + ] + }, + { + "cell_type": "markdown", + "id": "296416c3-c6b0-44ee-8ca3-64d9f42acc68", + "metadata": {}, + "source": [ + ":::{note}\n", + "`ReactComponent` extends the [`JSComponent`](JSComponent.md) class, which allows you to create custom Panel components using JavaScript.\n", + "\n", + "`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.\n", + "\n", + "If you are looking to create custom components using Python and Panel component only, check out [`Viewer`](Viewer.md).\n", + ":::\n", + "\n", + "## API\n", + "\n", + "### ReactComponent Attributes\n", + "\n", + "- **`_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.\n", + "- **`_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.\n", + "- **`_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.\n", + "\n", + ":::note\n", + "\n", + "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.\n", + "\n", + ":::\n", + "\n", + "#### `render` Function\n", + "\n", + "The `_esm` attribute must export the `render` function. It accepts the following parameters:\n", + "\n", + "- **`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.\n", + "- **`view`**: The Bokeh view.\n", + "- **`el`**: The HTML element that the component will be rendered into.\n", + "\n", + "Any React component returned from the `render` function will be appended to the HTML element (`el`) of the component.\n", + "\n", + "### State Hooks\n", + "\n", + "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:\n", + "\n", + "1. The current state. During the first render, it will match the initialState you have passed.\n", + "2. The set function that lets you update the state to a different value and trigger a re-render.\n", + "\n", + "Using the state value in your React component will automatically re-render the component when it is updated.\n", + "\n", + "### Callbacks\n", + "\n", + "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.\n", + "\n", + "#### Change Events\n", + "\n", + "The following signatures are valid when listening to change events:\n", + "\n", + "- `.on('', callback)`: Allows registering an event handler for a single parameter.\n", + "- `.on(['', ...], callback)`: Allows adding an event handler for multiple parameters at once.\n", + "- `.on('change:', callback)`: The `change:` prefix allows disambiguating change events from lifecycle hooks should a parameter name and lifecycle hook overlap.\n", + "\n", + "#### Lifecycle Hooks\n", + "\n", + "- `after_render`: Called once after the component has been fully rendered.\n", + "- `after_resize`: Called after the component has been resized.\n", + "- `remove`: Called when the component view is being removed from the DOM.\n", + "\n", + "## Usage\n", + "\n", + "### Styling with CSS\n", + "\n", + "Include CSS within the `_stylesheets` attribute to style the component. The CSS is injected directly into the component's HTML." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3f11eac1-32c0-405a-9eb3-c5dd715a2d89", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "import param\n", + "\n", + "from panel.custom import ReactComponent\n", + "\n", + "\n", + "class CounterButton(ReactComponent):\n", + "\n", + " value = param.Integer()\n", + "\n", + " _stylesheets = [\n", + " \"\"\"\n", + " button {\n", + " background: #0072B5;\n", + " color: white;\n", + " border: none;\n", + " padding: 10px;\n", + " border-radius: 4px;\n", + " }\n", + " button:hover {\n", + " background: #4099da;\n", + " }\n", + " \"\"\"\n", + " ]\n", + "\n", + " _esm = \"\"\"\n", + " export function render({ model }) {\n", + " const [value, setValue] = model.useState(\"value\");\n", + " return (\n", + " \n", + " );\n", + " }\n", + " \"\"\"\n", + "\n", + "CounterButton().servable()" + ] + }, + { + "cell_type": "markdown", + "id": "d01593d5-e955-4d4c-a552-f5805f1f93bf", + "metadata": {}, + "source": [ + "## Send Events from JavaScript to Python\n", + "\n", + "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)`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "58fbec62-51fd-4b8b-babf-d09957c19726", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "import param\n", + "\n", + "from panel.custom import ReactComponent\n", + "\n", + "pn.extension()\n", + "\n", + "class EventExample(ReactComponent):\n", + "\n", + " value = param.Parameter()\n", + "\n", + " _esm = \"\"\"\n", + " export function render({ model }) {\n", + " return (\n", + " \n", + " );\n", + " }\n", + " \"\"\"\n", + "\n", + " def _handle_click(self, event):\n", + " self.value = str(event.__dict__)\n", + "\n", + "button = EventExample()\n", + "pn.Column(\n", + " button, pn.widgets.TextAreaInput(value=button.param.value, height=200),\n", + ").servable()" + ] + }, + { + "cell_type": "markdown", + "id": "39dfcddb-09ab-4f87-816a-7a2c83b86ff4", + "metadata": {}, + "source": [ + "You can also define and send your own custom events:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "458d0dfe-a4e3-4326-999b-856426f1d4d4", + "metadata": {}, + "outputs": [], + "source": [ + "import datetime\n", + "\n", + "import panel as pn\n", + "import param\n", + "\n", + "from panel.custom import ReactComponent\n", + "\n", + "\n", + "class CustomEventExample(ReactComponent):\n", + "\n", + " value = param.String()\n", + "\n", + " _esm = \"\"\"\n", + " function send_event(model) {\n", + " const currentDate = new Date();\n", + " const custom_event = new CustomEvent(\"click\", { detail: currentDate.getTime() });\n", + " model.send_event('click', custom_event)\n", + " }\n", + "\n", + " export function render({ model }) {\n", + " return (\n", + " \n", + " );\n", + " }\n", + " \"\"\"\n", + "\n", + " def _handle_click(self, event):\n", + " unix_timestamp = event.data[\"detail\"]/1000\n", + " python_datetime = datetime.datetime.fromtimestamp(unix_timestamp)\n", + " self.value = str(python_datetime)\n", + "\n", + "button = CustomEventExample()\n", + "pn.Column(\n", + " button, button.param.value,\n", + ").servable()" + ] + }, + { + "cell_type": "markdown", + "id": "8dd62b24-2a26-49ff-8aad-386178128167", + "metadata": {}, + "source": [ + "## Dependency Imports\n", + "\n", + "JavaScript dependencies can be directly imported via URLs, such as those from [`esm.sh`](https://esm.sh/)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9df3c3ec-fe28-4043-b888-60418eae7e24", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "\n", + "from panel.custom import ReactComponent\n", + "\n", + "\n", + "class ConfettiButton(ReactComponent):\n", + "\n", + " _esm = \"\"\"\n", + " import confetti from \"https://esm.sh/canvas-confetti@1.6.0\";\n", + "\n", + " export function render() {\n", + " return (\n", + " \n", + " );\n", + " }\n", + " \"\"\"\n", + "\n", + "ConfettiButton().servable()" + ] + }, + { + "cell_type": "markdown", + "id": "0a9fe73e-c552-4cef-85e0-005386520d91", + "metadata": {}, + "source": [ + "Use the `_importmap` attribute for more concise module references." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5cac202e-f347-4132-b466-bd70713f027c", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "\n", + "from panel.custom import ReactComponent\n", + "\n", + "\n", + "class ConfettiButton(ReactComponent):\n", + " _importmap = {\n", + " \"imports\": {\n", + " \"canvas-confetti\": \"https://esm.sh/canvas-confetti@1.6.0\",\n", + " }\n", + " }\n", + "\n", + " _esm = \"\"\"\n", + " import confetti from \"canvas-confetti\";\n", + "\n", + " export function render() {\n", + " return (\n", + " \n", + " );\n", + " }\n", + " \"\"\"\n", + "\n", + "ConfettiButton().servable()" + ] + }, + { + "cell_type": "markdown", + "id": "6aec4fdd-152b-4fe8-a545-fd4b9daf989a", + "metadata": {}, + "source": [ + "See [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) for more info about the import map format.\n" + ] + }, + { + "cell_type": "markdown", + "id": "adc92e37-2f42-4166-80ca-712753021cea", + "metadata": {}, + "source": [ + "## External Files\n", + "\n", + "You can load JSX and CSS from files by providing the paths to these files.\n", + "\n", + "Create the file **counter_button.py**.\n", + "\n", + "```python\n", + "from pathlib import Path\n", + "\n", + "import param\n", + "import panel as pn\n", + "\n", + "from panel.custom import ReactComponent\n", + "\n", + "pn.extension()\n", + "\n", + "class CounterButton(ReactComponent):\n", + "\n", + " value = param.Integer()\n", + "\n", + " _esm = \"counter_button.jsx\"\n", + " _stylesheets = [Path(\"counter_button.css\")]\n", + "\n", + "CounterButton().servable()\n", + "```\n", + "\n", + "Now create the file **counter_button.jsx**.\n", + "\n", + "```javascript\n", + "export function render({ model }) {\n", + " const [value, setValue] = model.useState(\"value\");\n", + " return (\n", + " \n", + " );\n", + "}\n", + "```\n", + "\n", + "Now create the file **counter_button.css**.\n", + "\n", + "```css\n", + "button {\n", + " background: #0072B5;\n", + " color: white;\n", + " border: none;\n", + " padding: 10px;\n", + " border-radius: 4px;\n", + "}\n", + "button:hover {\n", + " background: #4099da;\n", + "}\n", + "```\n", + "\n", + "Serve the app with `panel serve counter_button.py --autoreload`.\n", + "\n", + "You can now edit the JSX or CSS file, and the changes will be automatically reloaded.\n", + "\n", + "- Try changing `count is {value}` to `COUNT IS {value}` and observe the update.\n", + "- Try changing the background color from `#0072B5` to `#008080`.\n", + "\n", + "## Displaying A Single Child\n", + "\n", + "You can display Panel components (`Viewable`s) by defining a `Child` parameter.\n", + "\n", + "Lets start with the simplest example\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19868da7-2d89-472c-a5a0-a286bb8319e3", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "\n", + "from panel.custom import Child, ReactComponent\n", + "\n", + "class Example(ReactComponent):\n", + "\n", + " child = Child()\n", + "\n", + " _esm = \"\"\"\n", + " export function render({ model }) {\n", + " return \n", + " }\n", + " \"\"\"\n", + "\n", + "Example(child=pn.panel(\"A **Markdown** pane!\")).servable()" + ] + }, + { + "cell_type": "markdown", + "id": "9373c469-befd-45d0-8049-3e4dd8be58bb", + "metadata": {}, + "source": [ + "If you provide a non-`Viewable` child it will automatically be converted to a `Viewable` by `pn.panel`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b1b98091-0e75-43c5-bb36-055d36e7bf5d", + "metadata": {}, + "outputs": [], + "source": [ + "Example(child=\"A **Markdown** pane!\").servable()" + ] + }, + { + "cell_type": "markdown", + "id": "9f3f2e65-ded4-4e19-8451-d10118a43d41", + "metadata": {}, + "source": [ + "If you want to allow a certain type of Panel components only you can specify the specific type in the `class_` argument." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f3419e6f-6990-412c-ab82-fd4fceba98c6", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "\n", + "from panel.custom import Child, ReactComponent\n", + "\n", + "class Example(ReactComponent):\n", + "\n", + " child = Child(class_=pn.pane.Markdown)\n", + "\n", + " _esm = \"\"\"\n", + " export function render({ model }) {\n", + " return \n", + " }\n", + " \"\"\"\n", + "\n", + "Example(child=pn.panel(\"A **Markdown** pane!\")).servable()" + ] + }, + { + "cell_type": "markdown", + "id": "868da08a-c73f-4338-96c7-4d2f6196fd2a", + "metadata": {}, + "source": [ + "The `class_` argument also supports a tuple of types:\n", + "\n", + "```python\n", + " child = Child(class_=(pn.pane.Markdown, pn.pane.HTML))\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "4d7d9fd8-a785-459f-95d2-c658547afe84", + "metadata": {}, + "source": [ + "## Displaying a List of Children\n", + "\n", + "You can also display a `List` of `Viewable` objects using the `Children` parameter type:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cded9c3f-9ce7-4e91-9ff9-cbb84f5074f7", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "\n", + "from panel.custom import Children, ReactComponent\n", + "\n", + "class Example(ReactComponent):\n", + "\n", + " objects = Children()\n", + "\n", + " _esm = \"\"\"\n", + " export function render({ model }) {\n", + " return
{model.get_child(\"objects\")}
\n", + " }\"\"\"\n", + "\n", + "\n", + "Example(\n", + " objects=[pn.panel(\"A **Markdown** pane!\"), pn.widgets.Button(name=\"Click me!\"), {\"text\": \"I'm shown as a JSON Pane\"}]\n", + ").servable()" + ] + }, + { + "cell_type": "markdown", + "id": "bf8087ea-c135-4edb-bb3a-e864f7b7be3e", + "metadata": {}, + "source": [ + ":::note\n", + "You can change the `item_type` to a specific subtype of `Viewable` or a tuple of `Viewable` subtypes.\n", + ":::\n", + "\n", + "## Using React Hooks\n", + "\n", + "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:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "472746d9-b237-4f85-9ba1-5abf9b6b130d", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "\n", + "from panel.custom import ReactComponent\n", + "\n", + "pn.extension()\n", + "\n", + "class CounterButton(ReactComponent):\n", + "\n", + " _esm = \"\"\"\n", + " let { useState } = React;\n", + "\n", + " export function render() {\n", + " const [value, setValue] = useState(0);\n", + " return (\n", + " \n", + " );\n", + " }\n", + " \"\"\"\n", + "\n", + "CounterButton().servable()" + ] + }, + { + "cell_type": "markdown", + "id": "6c93522e-ba75-49c9-a8e0-9dffd1c042f6", + "metadata": {}, + "source": [ + "## References\n", + "\n", + "### Tutorials\n", + "\n", + "- [Build Custom Components](../../../how_to/custom_components/reactive_esm/reactive_esm_layout.md)\n", + "\n", + "### How-To Guides\n", + "\n", + "- [Convert `AnyWidget` widgets](../../../how_to/migrate/anywidget/index.md)\n", + "\n", + "### Reference Guides\n", + "\n", + "- [`AnyWidgetComponent`](./AnyWidgetComponent.ipynb)\n", + "- [`JSComponent`](./JSComponent.ipynb)\n", + "- [`ReactComponent`](./ReactComponent.ipynb)" + ] + } + ], + "metadata": { + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/reference/custom_components/ReactComponent.md b/examples/reference/custom_components/ReactComponent.md deleted file mode 100644 index f80c4fc079..0000000000 --- a/examples/reference/custom_components/ReactComponent.md +++ /dev/null @@ -1,483 +0,0 @@ -# `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.ipynb b/examples/reference/custom_components/Viewer.ipynb new file mode 100644 index 0000000000..51c07997d1 --- /dev/null +++ b/examples/reference/custom_components/Viewer.ipynb @@ -0,0 +1,354 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "6dd69519-afc9-4065-ad12-8253c708f5af", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "\n", + "pn.extension()" + ] + }, + { + "cell_type": "markdown", + "id": "a796364a-f1cd-411a-b7fd-d4354794474e", + "metadata": {}, + "source": [ + "`Viewer` simplifies the creation of custom Panel components using Python and Panel components only." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9164b959-7dc5-4579-b65c-1b3827803ca0", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "import param\n", + "\n", + "from panel.viewable import Viewer\n", + "\n", + "\n", + "class CounterButton(Viewer):\n", + "\n", + " value = param.Integer()\n", + "\n", + " def __init__(self, **params):\n", + " super().__init__()\n", + " self._layout = pn.widgets.Button(\n", + " name=self._button_name, on_click=self._on_click, **params\n", + " )\n", + "\n", + " def _on_click(self, event):\n", + " self.value += 1\n", + "\n", + " @param.depends(\"value\")\n", + " def _button_name(self):\n", + " return f\"count is {self.value}\"\n", + "\n", + " def __panel__(self):\n", + " return self._layout\n", + "\n", + "CounterButton()" + ] + }, + { + "cell_type": "markdown", + "id": "78fda29d-eedd-45e4-a02c-e0771ac6b182", + "metadata": {}, + "source": [ + ":::{note}\n", + "If you are looking to create new components using JavaScript, check out [`JSComponent`](JSComponent.md), [`ReactComponent`](ReactComponent.md), or [`AnyWidgetComponent`](AnyWidgetComponent.md) instead.\n", + ":::\n", + "\n", + "## API\n", + "\n", + "### Attributes\n", + "\n", + "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.\n", + "\n", + "### Methods\n", + "\n", + "- **`__panel__`**: Must be implemented. Should return the Panel component or object to be displayed.\n", + "- **`servable`**: This method serves the component using Panel's built-in server when running `panel serve ...`.\n", + "- **`show`**: Displays the component in a new browser tab when running `python ...`.\n", + "\n", + "## Usage\n", + "\n", + "### Styling with CSS\n", + "\n", + "You can style the component by styling the component(s) returned by `__panel__` using their `styles` or `stylesheets` attributes.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "68f36432-40e1-4098-a195-12474dbf4a83", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "import param\n", + "\n", + "from panel.viewable import Viewer\n", + "\n", + "\n", + "class StyledCounterButton(Viewer):\n", + "\n", + " value = param.Integer()\n", + "\n", + " _stylesheets = [\n", + " \"\"\"\n", + " :host(.solid) .bk-btn.bk-btn-default\n", + " {\n", + " background: #0072B5;\n", + " color: white;\n", + " border: none;\n", + " padding: 10px;\n", + " border-radius: 4px;\n", + " }\n", + " :host(.solid) .bk-btn.bk-btn-default:hover {\n", + " background: #4099da;\n", + " }\n", + " \"\"\"\n", + " ]\n", + "\n", + " def __init__(self, **params):\n", + " super().__init__()\n", + "\n", + " self._layout = pn.widgets.Button(\n", + " name=self._button_name,\n", + " on_click=self._on_click,\n", + " stylesheets=self._stylesheets,\n", + " **params,\n", + " )\n", + "\n", + " def _on_click(self, event):\n", + " self.value += 1\n", + "\n", + " @param.depends(\"value\")\n", + " def _button_name(self):\n", + " return f\"Clicked {self.value} times\"\n", + "\n", + " def __panel__(self):\n", + " return self._layout\n", + "\n", + "\n", + "StyledCounterButton().servable()" + ] + }, + { + "cell_type": "markdown", + "id": "3b134810-94f9-4ccf-a257-aa07cb48d6f3", + "metadata": {}, + "source": [ + "See the [Apply CSS](../../how_to/styling/apply_css.md) guide for more information on styling Panel components.\n", + "\n", + "## Displaying A Single Child\n", + "\n", + "You can display Panel components (`Viewable`s) by defining a `Child` parameter.\n", + "\n", + "Let's start with the simplest example:\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "42f21642-ad17-4c21-b186-d894c90da554", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "\n", + "from panel.custom import Child\n", + "from panel.viewable import Viewer\n", + "\n", + "class SingleChild(Viewer):\n", + "\n", + " object = Child()\n", + "\n", + " def __panel__(self):\n", + " return pn.Column(\"A Single Child\", self.param.object.rx())\n", + "\n", + "single_child = SingleChild(object=pn.pane.Markdown(\"A **Markdown** pane!\"))\n", + "\n", + "single_child.servable()" + ] + }, + { + "cell_type": "markdown", + "id": "9ba02dcb-c83b-4212-ac1f-8e14e895d485", + "metadata": {}, + "source": [ + "Calling `self.param.object.rx()` creates a reactive expression which updates when the `object` parameter is updated.\n", + "\n", + "Let's replace the `object` with a `Button`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6e386a52-d256-47a9-9139-a0e81e7bad88", + "metadata": {}, + "outputs": [], + "source": [ + "single_child.object = pn.widgets.Button(name=\"Click me\")" + ] + }, + { + "cell_type": "markdown", + "id": "9dbf41ea-b18f-4421-977a-291612cda6f7", + "metadata": {}, + "source": [ + "Let's change it back" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "880176b1-5052-4da1-8fbb-cce9e39524ab", + "metadata": {}, + "outputs": [], + "source": [ + "single_child.object = pn.pane.Markdown(\"A **Markdown** pane!\")" + ] + }, + { + "cell_type": "markdown", + "id": "10cc810a-6001-49d3-a5e1-798252e96a5d", + "metadata": {}, + "source": [ + "If you provide a non-`Viewable` child it will automatically be converted to a `Viewable` by `pn.panel`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d929e9b1-62da-4122-bc78-7129988de486", + "metadata": {}, + "outputs": [], + "source": [ + "SingleChild(object=\"A **Markdown** pane!\").servable()" + ] + }, + { + "cell_type": "markdown", + "id": "417c59c4-4e0d-46d1-a762-98af457034d5", + "metadata": {}, + "source": [ + "If you want to allow a certain type of Panel components only, you can specify the specific type in the `class_` argument." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f02e8336-a3fa-4e58-b457-d71f5d84fd18", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "\n", + "from panel.custom import Child\n", + "from panel.viewable import Viewer\n", + "\n", + "class SingleChild(Viewer):\n", + "\n", + " object = Child(class_=pn.pane.Markdown)\n", + "\n", + " def __panel__(self):\n", + " return pn.Column(\"A Single Child\", self.param.object.rx())\n", + "\n", + "\n", + "SingleChild(object=pn.pane.Markdown(\"A **Markdown** pane!\")).servable()" + ] + }, + { + "cell_type": "markdown", + "id": "8ba7de66-21a3-4ab8-8eb2-0e6fd7d488fd", + "metadata": {}, + "source": [ + "The `class_` argument also supports a tuple of types:\n", + "\n", + "```python\n", + " object = Child(class_=(pn.pane.Markdown, pn.widgets.Button))\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "000a0276-717b-4984-9d1e-e936ba8c1a05", + "metadata": {}, + "source": [ + "## Displaying a List of Children\n", + "\n", + "You can also display a `List` of `Viewable` objects using the `Children` parameter type:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a4fa17c5-65d2-4c1a-a311-aad61a4bf433", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "\n", + "from panel.custom import Children\n", + "from panel.viewable import Viewer\n", + "\n", + "\n", + "class MultipleChildren(Viewer):\n", + "\n", + " objects = Children()\n", + "\n", + " def __init__(self, **params):\n", + " super().__init__(**params)\n", + " self._layout = pn.Column(objects=self.param['objects'], styles={\"background\": \"silver\"})\n", + "\n", + " def __panel__(self):\n", + " return self._layout\n", + "\n", + "\n", + "MultipleChildren(\n", + " objects=[\n", + " pn.panel(\"A **Markdown** pane!\"),\n", + " pn.widgets.Button(name=\"Click me!\"),\n", + " {\"text\": \"I'm shown as a JSON Pane\"},\n", + " ]\n", + ").servable()\n" + ] + }, + { + "cell_type": "markdown", + "id": "60ebdbd0-48c8-4f21-9a57-7b49e31523ae", + "metadata": {}, + "source": [ + ":::note\n", + "You can change the `item_type` to a specific subtype of `Viewable` or a tuple of `Viewable` subtypes.\n", + ":::\n", + "\n", + "## References\n", + "\n", + "### Tutorials\n", + "\n", + "- [Reusable Components](../../../tutorials/intermediate/reusable_components.md)\n", + "\n", + "### How-To Guides\n", + "\n", + "- [Combine Existing Widgets](../../../how_to/custom_components/custom_viewer.md)\n" + ] + } + ], + "metadata": { + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/reference/custom_components/Viewer.md b/examples/reference/custom_components/Viewer.md deleted file mode 100644 index aa634b1ffc..0000000000 --- a/examples/reference/custom_components/Viewer.md +++ /dev/null @@ -1,258 +0,0 @@ -# `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/examples/reference/layouts/Tabs.ipynb b/examples/reference/layouts/Tabs.ipynb index 73897b7e7b..0f91fd9dd8 100644 --- a/examples/reference/layouts/Tabs.ipynb +++ b/examples/reference/layouts/Tabs.ipynb @@ -2,13 +2,13 @@ "cells": [ { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "import panel as pn\n", "pn.extension()" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -45,9 +45,7 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "from bokeh.plotting import figure\n", "\n", @@ -59,7 +57,9 @@ "\n", "tabs = pn.Tabs(('Scatter', p1), p2)\n", "tabs" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -70,15 +70,15 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "p3 = figure(width=300, height=300, name='Square')\n", - "p3.square([0, 1, 2, 3, 4, 5, 6], [0, 1, 2, 3, 2, 1, 0], size=10)\n", + "p3.scatter([0, 1, 2, 3, 4, 5, 6], [0, 1, 2, 3, 2, 1, 0], marker='square', size=10)\n", "\n", "tabs.append(p3)" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -89,12 +89,12 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "tabs" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -112,13 +112,13 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "print(tabs.active)\n", "tabs.active = 0" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -131,14 +131,14 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "tabs = pn.Tabs(p1, p2, p3, dynamic=True)\n", "\n", "tabs" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -149,9 +149,7 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "import time\n", "import numpy as np\n", @@ -171,7 +169,9 @@ "tabs = pn.Tabs(p1, p2, p3, dynamic=True)\n", "\n", "tabs" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -184,9 +184,7 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "tabs = pn.Tabs(\n", " ('red', pn.Spacer(styles=dict(background='red'), width=100, height=100)),\n", @@ -196,7 +194,9 @@ ")\n", "\n", "tabs" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -209,12 +209,12 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": {}, - "outputs": [], "source": [ "pn.Row(tabs, tabs.clone(active=1, tabs_location='right'), tabs.clone(active=2, tabs_location='below'), tabs.clone(tabs_location='left'))" - ] + ], + "outputs": [], + "execution_count": null } ], "metadata": { diff --git a/examples/reference/panes/DataFrame.ipynb b/examples/reference/panes/DataFrame.ipynb index 1747404f96..f97c565fdc 100644 --- a/examples/reference/panes/DataFrame.ipynb +++ b/examples/reference/panes/DataFrame.ipynb @@ -43,6 +43,7 @@ "* **``render_links``** (boolean, default=False): Convert URLs to HTML links.\n", "* **``show_dimensions``** (boolean, default=False): Display DataFrame dimensions (number of rows by number of columns).\n", "* **``sparsify``** (boolean, default=True): Set to False for a DataFrame with a hierarchical index to print every multi-index key at each row.\n", + "* **``text_align``** (str): How to justify the non-header cells ('left', 'right', 'center')\n", "\n", "___" ] diff --git a/examples/reference/panes/HTML.ipynb b/examples/reference/panes/HTML.ipynb index 1bccb8372e..73862ca03f 100644 --- a/examples/reference/panes/HTML.ipynb +++ b/examples/reference/panes/HTML.ipynb @@ -22,6 +22,7 @@ "For details on other options for customizing the component see the [layout](../../how_to/layout/index.md) and [styling](../../how_to/styling/index.md) how-to guides.\n", "\n", "* **`disable_math`** (boolean, `default=True`): Whether to disable MathJax math rendering for strings escaped with `$$` delimiters.\n", + "* **`enable_streaming`** (boolean, `default=False`): Whether to enable streaming of text snippets. This will diff the `object` when it is updated and only send the trailing chunk that was added.\n", "* **`object`** (str or object): The string or object with ``_repr_html_`` method to display\n", "* **`sanitize_html`** (boolean, `default=False`): Whether to sanitize HTML sent to the frontend.\n", "* **`sanitize_hook`** (Callable, `default=bleach.clean`): Sanitization callback to apply if `sanitize_html=True`.\n", diff --git a/examples/reference/panes/Markdown.ipynb b/examples/reference/panes/Markdown.ipynb index 93eeeb7541..ef4f0055e1 100644 --- a/examples/reference/panes/Markdown.ipynb +++ b/examples/reference/panes/Markdown.ipynb @@ -25,6 +25,7 @@ "\n", "* **`dedent`** (bool): Whether to dedent common whitespace across all lines.\n", "* **`disable_math`** (boolean, `default=False`): Whether to disable MathJax math rendering for strings escaped with `$$` delimiters.\n", + "* **`enable_streaming`** (boolean, `default=False`): Whether to enable streaming of text snippets. This will diff the `object` when it is updated and only send the trailing chunk that was added.\n", "* **`extensions`** (list): A list of [Python-Markdown extensions](https://python-markdown.github.io/extensions/) to use (does not apply for 'markdown-it' and 'myst' renderers).\n", "* **`object`** (str or object): A string containing Markdown, or an object with a ``_repr_markdown_`` method.\n", "* **`plugins`** (function): A list of additional markdown-it-py plugins to apply.\n", diff --git a/examples/reference/panes/Param.ipynb b/examples/reference/panes/Param.ipynb index e5a99fdf3b..a3f865bde4 100644 --- a/examples/reference/panes/Param.ipynb +++ b/examples/reference/panes/Param.ipynb @@ -290,26 +290,6 @@ "Let's put a plot of the PowerCurve in the mix." ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "sections = {'InputFiles': ['dirs', 'files'], 'Field': ['grps', 'varns']}\n", - "\n", - "def update(target, event):\n", - " target.param.update(options=sections[event.new], value=sections[event.new][0])\n", - "\n", - "sel = pn.widgets.Select(options=list(sections.keys()))\n", - "rad = pn.widgets.RadioButtonGroup(options=sections[sel.value])\n", - "sel.link(rad, callbacks={'value': update})\n", - "\n", - "pn.Column(sel, rad)" - ] - }, { "cell_type": "code", "execution_count": null, diff --git a/examples/reference/panes/Vega.ipynb b/examples/reference/panes/Vega.ipynb index b008a74aef..8ef1c8a9c6 100644 --- a/examples/reference/panes/Vega.ipynb +++ b/examples/reference/panes/Vega.ipynb @@ -6,7 +6,9 @@ "metadata": {}, "outputs": [], "source": [ + "import pandas as pd\n", "import panel as pn\n", + "\n", "pn.extension('vega')" ] }, @@ -14,29 +16,33 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The ``Vega`` pane renders Vega-based plots (including those from Altair) inside a panel. It optimizes the plot rendering by using binary serialization for any array data found on the Vega/Altair object, providing huge speedups over the standard JSON serialization employed by Vega natively. Note that to use the ``Vega`` pane in the notebook the Panel extension has to be loaded with 'vega' as an argument to ensure that vega.js is initialized.\n", + "The ``Vega`` pane renders Vega-based plots (including those from Altair) inside a panel. It optimizes plot rendering by using binary serialization for any array data found in the Vega/Altair object, providing significant speedups over the standard JSON serialization employed by Vega natively. Note that to use the ``Vega`` pane in the notebook, the Panel extension must be loaded with 'vega' as an argument to ensure that vega.js is initialized.\n", "\n", "#### Parameters:\n", "\n", - "For details on other options for customizing the component see the [layout](../../how_to/layout/index.md) and [styling](../../how_to/styling/index.md) how-to guides.\n", + "For details on other options for customizing the component, see the [layout](../../how_to/layout/index.md) and [styling](../../how_to/styling/index.md) how-to guides.\n", "\n", "* **``debounce``** (int or dict): The debounce timeout to apply to selection events, either specified as a single integer value (in milliseconds) or a dictionary that declares a debounce value per event. Debouncing ensures that events are only dispatched N milliseconds after a user is done interacting with the plot.\n", - "* **``object``** (dict or altair Chart): Either a dictionary containing a Vega or Vega-Lite plot specification, or an Altair Chart\n", - "* **``theme``** (str): A theme to apply to the plot, must be one of 'excel', 'ggplot2', 'quartz', 'vox', 'fivethirtyeight', 'dark', 'latimes', 'urbaninstitute', 'googlecharts'.\n", - "* **``show_actions``** (boolean): Whether to show chart actions menu such as save, edit etc.\n", + "* **``object``** (dict or altair Chart): Either a dictionary containing a Vega or Vega-Lite plot specification, or an Altair Chart.\n", + "* **``show_actions``** (boolean): Whether to show the chart actions menu, such as save, edit, etc.\n", + "* **``theme``** (str): A theme to apply to the plot. Must be one of 'excel', 'ggplot2', 'quartz', 'vox', 'fivethirtyeight', 'dark', 'latimes', 'urbaninstitute', or 'googlecharts'.\n", "\n", "Readonly parameters:\n", "\n", - "* **``selection``** (Selection): The Selection object exposes parameters which reflect the selections declared on the plot into Python. \n", + "* **``selection``** (Selection): The Selection object exposes parameters that reflect the selections declared on the plot into Python.\n", + "\n", + "___\n", "\n", - "___" + "The ``Vega`` pane supports both [`vega`](https://vega.github.io/vega/docs/specification/) and [`vega-lite`](https://vega.github.io/vega-lite/docs/spec.html) specifications, which may be provided in raw form (i.e., a dictionary) or by defining an ``altair`` plot.\n", + "\n", + "---" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "The ``Vega`` pane supports both ``vega`` and ``vega-lite`` specs which may be provided in a raw form (i.e. a dictionary) or by defining an ``altair`` plot.\n", + "### Vega and Vega-lite\n", "\n", "To display ``vega`` and ``vega-lite`` specification simply construct a ``Vega`` pane directly or pass it to ``pn.panel``:" ] @@ -91,7 +97,7 @@ "metadata": {}, "outputs": [], "source": [ - "vgl_pane.object = {\n", + "vega_disasters = {\n", " \"$schema\": \"https://vega.github.io/schema/vega-lite/v5.json\",\n", " \"data\": {\n", " \"url\": \"https://raw.githubusercontent.com/vega/vega/master/docs/data/disasters.csv\"\n", @@ -127,13 +133,32 @@ " },\n", " \"color\": {\"field\": \"Entity\", \"type\": \"nominal\", \"legend\": None}\n", " }\n", - "}" + "}\n", + "vgl_pane.object = vega_disasters" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Lets reset the plot back to the original:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vgl_pane.object = vegalite" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ + "#### Responsive Sizing\n", + "\n", "The `vega-lite` specification can also be responsively sized by declaring the width or height to match the container:" ] }, @@ -143,7 +168,7 @@ "metadata": {}, "outputs": [], "source": [ - "responsive_spec = dict(vgl_pane.object, width='container')\n", + "responsive_spec = dict(vega_disasters, width='container', title=\"Responsive Plot\")\n", "\n", "vgl_responsive_pane = pn.pane.Vega(responsive_spec)\n", "vgl_responsive_pane" @@ -156,6 +181,36 @@ "Please note that the `vega` specification does not support setting `width` and `height` to `container`." ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### DataFrame Data Values\n", + "\n", + "For convenience we support a Pandas DataFrame as `data` `values`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "dataframe_spec = {\n", + " \"title\": \"A Simple Bar Chart from a Pandas DataFrame\",\n", + " 'config': {\n", + " 'mark': {'tooltip': None},\n", + " 'view': {'height': 200, 'width': 500}\n", + " },\n", + " 'data': {'values': pd.DataFrame({'x': ['A', 'B', 'C', 'D', 'E'], 'y': [5, 3, 6, 7, 2]})},\n", + " 'mark': 'bar',\n", + " 'encoding': {'x': {'type': 'ordinal', 'field': 'x'},\n", + " 'y': {'type': 'quantitative', 'field': 'y'}},\n", + " '$schema': 'https://vega.github.io/schema/vega-lite/v3.2.1.json'\n", + "}\n", + "pn.pane.Vega(dataframe_spec)" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/examples/reference/widgets/CodeEditor.ipynb b/examples/reference/widgets/CodeEditor.ipynb index 4232d91ed3..abab154058 100644 --- a/examples/reference/widgets/CodeEditor.ipynb +++ b/examples/reference/widgets/CodeEditor.ipynb @@ -37,11 +37,12 @@ " - `'type'`: type of annotation and the icon displayed {`warning` | `error`}\n", "* **``filename``** (str): If filename is provided the file extension will be used to determine the language\n", "* **``language``** (str): A string declaring which language to use for code syntax highlighting (default: 'text')\n", + "* **``on_keyup``** (bool): Whether to update the value on every key press or only upon loss of focus / hotkeys.\n", "* **``print_margin``** (boolean): Whether to show a print margin in the editor\n", "* **``theme``** (str): theme of the editor (default: 'chrome')\n", "* **``readonly``** (boolean): Whether the editor should be opened in read-only mode\n", - "* **``value``** (str): A string with (initial) code to set in the editor\n", - "\n", + "* **``value``** (str): State of the current code in the editor if `on_keyup`. Otherwise, only upon loss of focus, i.e. clicking outside the editor, or pressing or .\n", + "* **``value_input``** (str): State of the current code updated on every key press. Identical to `value` if `on_keyup`.\n", "___" ] }, @@ -50,7 +51,7 @@ "metadata": {}, "source": [ "To construct an `Ace` widget we must define it explicitly using `pn.widgets.Ace`. We can add some text as initial code.\n", - "Code inserted in the editor is automatically reflected in the `value`." + "Code inserted in the editor is automatically reflected in the `value_input` and `value`." ] }, { @@ -84,6 +85,26 @@ "\"\"\"" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "By default, the code editor will update the `value` on every key press, but you can set `on_keyup=False` to only update the `value` when the editor loses focus or pressing ``/ ``. Here, the code is printed when `value` is changed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def print_code(value):\n", + " print(value)\n", + "\n", + "editor = pn.widgets.CodeEditor(value=py_code, on_keyup=False)\n", + "pn.bind(print_code, editor.param.value)" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/examples/reference/widgets/DiscretePlayer.ipynb b/examples/reference/widgets/DiscretePlayer.ipynb index 9323014579..36599f913c 100644 --- a/examples/reference/widgets/DiscretePlayer.ipynb +++ b/examples/reference/widgets/DiscretePlayer.ipynb @@ -37,7 +37,12 @@ "\n", "* **``disabled``** (boolean): Whether the widget is editable\n", "* **``name``** (str): The title of the widget\n", + "* **``scale_buttons``** (float): The scaling factor to resize the buttons\n", "* **``show_loop_controls``** (boolean): Whether radio buttons allowing to switch between loop policies options are shown\n", + "* **``show_value``** (boolean): Whether to display the value of the player\n", + "* **``value_align``** (str): Where to display the value; must be one of 'start', 'center', 'end'\n", + "* **``visible_buttons``** (list[str]): The buttons to display on the player ('slower', 'first', 'previous', 'reverse', 'pause', 'play', 'next', 'last', 'faster')\n", + "* **``visible_loop_options``** (list[str]): The loop options to display on the player. ('once', 'loop', 'reflect')\n", "\n", "___" ] @@ -55,7 +60,8 @@ "metadata": {}, "outputs": [], "source": [ - "discrete_player = pn.widgets.DiscretePlayer(name='Discrete Player', options=[2, 4, 8, 16, 32, 64, 128], value=8, loop_policy='loop')\n", + "discrete_player = pn.widgets.DiscretePlayer(name='Discrete Player', options=[2, 4, 8, 16, 32, 64, 128],\n", + " value=8, loop_policy='loop', show_value=True, value_align='start')\n", "\n", "discrete_player" ] @@ -104,6 +110,23 @@ "discrete_player.pause()" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The `DiscretePlayer` can be slimmed down by setting `scale_buttons`, `show_loop_controls`, `visible_buttons`, and/or `visible_loop_options`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "discrete_player = pn.widgets.DiscretePlayer(name='Player', visible_buttons=[\"play\", \"pause\"], scale_buttons=0.9, show_loop_controls=False, width=150)\n", + "discrete_player" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/examples/reference/widgets/Player.ipynb b/examples/reference/widgets/Player.ipynb index 238b852a96..212b882825 100644 --- a/examples/reference/widgets/Player.ipynb +++ b/examples/reference/widgets/Player.ipynb @@ -39,7 +39,12 @@ "\n", "* **``disabled``** (boolean): Whether the widget is editable\n", "* **``name``** (str): The title of the widget\n", + "* **``scale_buttons``** (float): The scaling factor to resize the buttons\n", "* **``show_loop_controls``** (boolean): Whether radio buttons allowing to switch between loop policies options are shown\n", + "* **``show_value``** (boolean): Whether to display the value of the player\n", + "* **``value_align``** (str): Where to display the value; must be one of 'start', 'center', 'end'\n", + "* **``visible_buttons``** (list[str]): The buttons to display on the player ('slower', 'first', 'previous', 'reverse', 'pause', 'play', 'next', 'last', 'faster')\n", + "* **``visible_loop_options``** (list[str]): The loop options to display on the player. ('once', 'loop', 'reflect')\n", "\n", "___" ] @@ -57,7 +62,8 @@ "metadata": {}, "outputs": [], "source": [ - "player = pn.widgets.Player(name='Player', start=0, end=100, value=32, loop_policy='loop')\n", + "player = pn.widgets.Player(name='Player', start=0, end=100, value=32, loop_policy='loop',\n", + " show_value=True, value_align='start')\n", "\n", "player" ] @@ -106,6 +112,23 @@ "player.pause()" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The `Player` can be slimmed down by setting `scale_buttons`, `show_loop_controls`, `visible_buttons`, and/or `visible_loop_options`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "player = pn.widgets.Player(name='Player', visible_buttons=[\"play\", \"pause\"], scale_buttons=0.9, show_loop_controls=False, width=150)\n", + "player" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/examples/reference/widgets/Tabulator.ipynb b/examples/reference/widgets/Tabulator.ipynb index c061c224ef..206f252fbe 100644 --- a/examples/reference/widgets/Tabulator.ipynb +++ b/examples/reference/widgets/Tabulator.ipynb @@ -19,7 +19,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The `Tabulator` widget allows displaying and editing a pandas DataFrame. The `Tabulator` is a largely backward compatible replacement for the [`DataFrame`](./DataFrame.ipynb) widget and will eventually replace it. It is built on the **version 5.5** of the [Tabulator](http://tabulator.info/) library, which provides for a wide range of features.\n", + "The `Tabulator` widget allows displaying and editing a pandas DataFrame. The `Tabulator` is a largely backward compatible replacement for the [`DataFrame`](./DataFrame.ipynb) widget and will eventually replace it. It is built on the **version {{TABULATOR_VERSION}}** of the [Tabulator](http://tabulator.info/) library, which provides for a wide range of features.\n", "\n", "Discover more on using widgets to add interactivity to your applications in the [how-to guides on interactivity](../how_to/interactivity/index.md). Alternatively, learn [how to set up callbacks and (JS-)links between parameters](../../how_to/links/index.md) or [how to use them as part of declarative UIs with Param](../../how_to/param/index.html).\n", "\n", @@ -49,9 +49,10 @@ "* **`header_filters`** (`boolean`/`dict`): A boolean enabling filters in the column headers or a dictionary providing filter definitions for specific columns.\n", "* **`hidden_columns`** (`list`): List of columns to hide.\n", "* **`hierarchical`** (boolean, default=False): Whether to render multi-indexes as hierarchical index (note hierarchical must be enabled during instantiation and cannot be modified later)\n", + "* **`initial_page_size`** (`int`, `default=20`): If pagination is enabled and `page_size` this determines the initial size of each page before rendering.\n", "* **`layout`** (`str`, `default='fit_data_table'`): Describes the column layout mode with one of the following options `'fit_columns'`, `'fit_data'`, `'fit_data_stretch'`, `'fit_data_fill'`, `'fit_data_table'`. \n", "* **`page`** (`int`, `default=1`): Current page, if pagination is enabled.\n", - "* **`page_size`** (`int`, `default=20`): Number of rows on each page, if pagination is enabled.\n", + "* **`page_size`** (`int | None`, `default=None`): Number of rows on each page, if pagination is enabled. By default the number of rows is automatically determined based on the number of rows that fit on screen. If None the initial amount of data is determined by the `initial_page_size`. \n", "* **`pagination`** (`str`, `default=None`): Set to `'local` or `'remote'` to enable pagination; by default pagination is disabled with the value set to `None`.\n", "* **`row_content`** (`callable`): A function that receives the expanded row (`pandas.Series`) as input and should return a Panel object to render into the expanded region below the row.\n", "* **`selection`** (`list`): The currently selected rows as a list of integer indexes.\n", @@ -822,6 +823,8 @@ "cell_type": "markdown", "metadata": {}, "source": [ + "Note that the default `page_size` is None, which means it will measure the height of the rows and try to fit the appropriate number of rows into the available space. To override the number of rows sent to the frontend before the measurement has taken place set the `initial_page_size`.\n", + "\n", "Contrary to the `'remote'` option, `'local'` pagination transfers all of the data but still allows to display it on multiple pages:" ] }, diff --git a/lite/requirements.txt b/lite/requirements.txt deleted file mode 100644 index 47627c27e6..0000000000 --- a/lite/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -awscli -bokeh -ipywidgets -jupyter_bokeh -jupyterlab -jupyterlab-open-url-parameter -jupyterlite-core <0.3.0 -jupyterlite-pyodide-kernel -nbformat -pkginfo -pyviz_comms diff --git a/panel/.eslintrc.js b/panel/.eslintrc.js index 66a06d4024..22c52b0732 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", "examples/*"], + "ignorePatterns": ["*/dist", "*/theme/**/*.js", ".eslintrc.js", "*/_templates/*.js", "*/template/**/*.js", "examples/*", "scripts/*"], "rules": { "@typescript-eslint/ban-types": ["error", { "types": { @@ -48,6 +48,7 @@ module.exports = { "@typescript-eslint/no-unnecessary-type-assertion": ["error"], "@typescript-eslint/no-unnecessary-type-constraint": ["error"], "@typescript-eslint/switch-exhaustiveness-check": ["error"], + "no-console": ["error", { "allow": ["warn", "error"] }], "no-self-assign": ["error", {"props": false}], "brace-style": ["error", "1tbs", {"allowSingleLine": true}], "comma-dangle": ["off"], diff --git a/panel/chat/feed.py b/panel/chat/feed.py index c37dabfe30..a0924f46e7 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -22,13 +22,17 @@ from .._param import Margin from ..io.resources import CDN_DIST -from ..layout import Column, Feed, ListPanel +from ..layout import ( + Column, Feed, ListPanel, WidgetBox, +) from ..layout.card import Card from ..layout.spacer import VSpacer from ..pane.image import SVG, ImageBase -from ..pane.markup import Markdown +from ..pane.markup import HTML, Markdown from ..util import to_async_gen from ..viewable import Children +from ..widgets import Widget +from ..widgets.button import Button from .icon import ChatReactionIcons from .message import ChatMessage from .step import ChatStep @@ -198,6 +202,8 @@ class ChatFeed(ListPanel): _callback_state = param.ObjectSelector(objects=list(CallbackState), doc=""" The current state of the callback.""") + _prompt_trigger = param.Event(doc="Triggers the prompt input.") + _callback_trigger = param.Event(doc="Triggers the callback to respond.") _post_hook_trigger = param.Event(doc="Triggers the append callback.") @@ -337,6 +343,13 @@ def _update_placeholder(self): **self.placeholder_params ) + @param.depends("loading", watch=True, on_init=True) + def _show_placeholder(self): + if self.loading: + self.append(self._placeholder) + else: + self._replace_placeholder(None) + def _replace_placeholder(self, message: ChatMessage | None = None) -> None: """ Replace the placeholder from the chat log with the message @@ -348,6 +361,8 @@ def _replace_placeholder(self, message: ChatMessage | None = None) -> None: self.append(message) try: + if self.loading: + return self.remove(self._placeholder) except ValueError: pass @@ -705,7 +720,9 @@ def add_step( append: bool = True, user: str | None = None, avatar: str | bytes | BytesIO | None = None, - steps_column: Column | None = None, + steps_layout: Column | Card | None = None, + default_layout: Literal["column", "card"] = "card", + layout_params: dict | None = None, **step_params ) -> ChatStep: """ @@ -724,9 +741,15 @@ def add_step( avatar : str | bytes | BytesIO | None The avatar to use; overrides the message's avatar if provided. Will default to the avatar parameter. Only applicable if steps is "new". - steps_column : Column | None - An existing Column of steps to stream to, if None is provided + steps_layout : Column | None + An existing layout of steps to stream to, if None is provided it will default to the last Column of steps or create a new one. + default_layout : str + The default layout to use if steps_layout is None. + 'column' will create a new Column layout. + 'card' will create a new Card layout. + layout_params : dict | None + Additional parameters to pass to the layout. step_params : dict Parameters to pass to the ChatStep. """ @@ -735,6 +758,8 @@ def add_step( step = [] elif not isinstance(step, list): step = [step] + if "margin" not in step_params: + step_params["margin"] = (5, 1) step_params["objects"] = [ ( Markdown(obj, css_classes=["step-message"]) @@ -752,20 +777,121 @@ def add_step( all(isinstance(o, ChatStep) for o in last.object) or last.object.css_classes == 'chat-steps' ) and (user is None or last.user == user): - steps_column = last.object - if steps_column is None: - steps_column = Column( - step, css_classes=["chat-steps"], styles={ - 'max-width': 'calc(100% - 30px)', - 'padding-block': '0px' - } + steps_layout = last.object + if steps_layout is None: + layout_params = layout_params or {} + input_layout_params = dict( + min_width=100, + styles={ + "margin-inline": "10px", + }, + css_classes=["chat-steps"], + stylesheets=[f"{CDN_DIST}css/chat_steps.css"] ) - self.stream(steps_column, user=user, avatar=avatar) + if default_layout == "column": + layout = Column + elif default_layout == "card": + layout = Card + input_layout_params["header_css_classes"] = ["card-header"] + title = layout_params.pop("title", None) + input_layout_params["header"] = HTML( + title or "🪜 Steps", + css_classes=["card-title"], + stylesheets=[f"{CDN_DIST}css/chat_steps.css"] + ) + else: + raise ValueError( + f"Invalid default_layout {default_layout!r}; " + f"expected 'column' or 'card'." + ) + if layout_params: + input_layout_params.update(layout_params) + steps_layout = layout(step, **input_layout_params) + self.stream(steps_layout, user=user or self.callback_user, avatar=avatar) else: - steps_column.append(step) + steps_layout.append(step) self._chat_log.scroll_to_latest() return step + def prompt_user( + self, + component: Widget | ListPanel, + callback: Callable | None = None, + predicate: Callable | None = None, + timeout: int = 120, + timeout_message: str = "Timed out", + button_params: dict | None = None, + timeout_button_params: dict | None = None, + **send_kwargs + ) -> None: + """ + Prompts the user to interact with a form component. + + Arguments + --------- + component : Widget | ListPanel + The component to prompt the user with. + callback : Callable + The callback to execute once the user submits the form. + The callback should accept two arguments: the component + and the ChatFeed instance. + predicate : Callable | None + A predicate to evaluate the component's state, e.g. widget has value. + If provided, the button will be enabled when the predicate returns True. + The predicate should accept the component as an argument. + timeout : int + The duration in seconds to wait before timing out. + timeout_message : str + The message to display when the timeout is reached. + button_params : dict | None + Additional parameters to pass to the submit button. + timeout_button_params : dict | None + Additional parameters to pass to the timeout button. + """ + async def _prepare_prompt(*_) -> None: + input_button_params = button_params or {} + if "name" not in input_button_params: + input_button_params["name"] = "Submit" + if "margin" not in input_button_params: + input_button_params["margin"] = (5, 10) + if "button_type" not in input_button_params: + input_button_params["button_type"] = "primary" + if "icon" not in input_button_params: + input_button_params["icon"] = "check" + submit_button = Button(**input_button_params) + + form = WidgetBox(component, submit_button, margin=(5, 10), css_classes=["message"]) + if "user" not in send_kwargs: + send_kwargs["user"] = "Input" + self.send(form, respond=False, **send_kwargs) + + for _ in range(timeout * 10): # sleeping for 0.1 seconds + is_fulfilled = predicate(component) if predicate else True + submit_button.disabled = not is_fulfilled + if submit_button.clicks > 0: + with param.parameterized.batch_call_watchers(self): + submit_button.visible = False + form.disabled = True + if callback is not None: + result = callback(component, self) + if isawaitable(result): + await result + break + await asyncio.sleep(0.1) + else: + input_timeout_button_params = timeout_button_params or {} + if "name" not in input_timeout_button_params: + input_timeout_button_params["name"] = timeout_message + if "button_type" not in input_timeout_button_params: + input_timeout_button_params["button_type"] = "light" + if "icon" not in input_timeout_button_params: + input_timeout_button_params["icon"] = "x" + with param.parameterized.batch_call_watchers(self): + submit_button.param.update(**input_timeout_button_params) + form.disabled = True + + param.parameterized.async_executor(_prepare_prompt) + def respond(self): """ Executes the callback with the latest message in the chat log. @@ -833,7 +959,8 @@ def _serialize_for_transformers( messages: list[ChatMessage], role_names: dict[str, str | list[str]] | None = None, default_role: str | None = "assistant", - custom_serializer: Callable = None + custom_serializer: Callable | None = None, + **serialize_kwargs ) -> list[dict[str, Any]]: """ Exports the chat log for use with transformers. @@ -873,7 +1000,7 @@ def _serialize_for_transformers( f"it returned a {type(content)} type" ) else: - content = str(message) + content = message.serialize(**serialize_kwargs) serialized_messages.append({"role": role, "content": content}) return serialized_messages diff --git a/panel/chat/icon.py b/panel/chat/icon.py index d7c2729323..cb91895dfb 100644 --- a/panel/chat/icon.py +++ b/panel/chat/icon.py @@ -7,7 +7,7 @@ import param from ..io.resources import CDN_DIST -from ..layout import Column +from ..layout import Column, Panel from ..reactive import ReactiveHTML from ..widgets.base import CompositeWidget from ..widgets.icon import ToggleIcon @@ -47,6 +47,9 @@ class ChatReactionIcons(CompositeWidget): value = param.List(default=[], doc="The active reactions.") + default_layout = param.ClassSelector( + default=Column, class_=Panel, is_instance=False) + _stylesheets: ClassVar[list[str]] = [f"{CDN_DIST}css/chat_reaction_icons.css"] _composite_type = Column @@ -69,7 +72,7 @@ def _render_icons(self): icon._reaction = option icon.param.watch(self._update_value, "value") self._rendered_icons[option] = icon - self._composite[:] = list(self._rendered_icons.values()) + self._composite[:] = [self.default_layout(*list(self._rendered_icons.values()))] @param.depends("value", watch=True) def _update_icons(self): @@ -93,24 +96,33 @@ def _update_value(self, event): class ChatCopyIcon(ReactiveHTML): + """ + ChatCopyIcon copies the value to the clipboard when clicked. + To avoid sending the value to the frontend the value is only + synced after the icon is clicked. + """ + + css_classes = param.List(default=["copy-icon"], doc="The CSS classes of the widget.") fill = param.String(default="none", doc="The fill color of the icon.") - value = param.String(default=None, doc="The text to copy to the clipboard.") + value = param.String(default=None, doc="The text to copy to the clipboard.", precedence=-1) - css_classes = param.List(default=["copy-icon"], doc="The CSS classes of the widget.") + _synced = param.String(default=None, doc="The text to copy to the clipboard.") + + _request_sync = param.Integer(default=0) _template = """
- @@ -119,10 +131,21 @@ class ChatCopyIcon(ReactiveHTML):
""" - _scripts = {"copy_to_clipboard": """ - navigator.clipboard.writeText(`${data.value}`); - data.fill = "currentColor"; - setTimeout(() => data.fill = "none", 50); - """} + _scripts = { + "render": "copy_icon.setAttribute('fill', data.fill)", + "fill": "copy_icon.setAttribute('fill', data.fill)", + "request_value": """ + data._request_sync += 1; + data.fill = "currentColor"; + """, + "_synced": """ + navigator.clipboard.writeText(`${data._synced}`); + data.fill = "none"; + """ + } _stylesheets: ClassVar[list[str]] = [f"{CDN_DIST}css/chat_copy_icon.css"] + + @param.depends('_request_sync', watch=True) + def _sync(self): + self._synced = self.value diff --git a/panel/chat/interface.py b/panel/chat/interface.py index 9c8df18e61..49998e62b3 100644 --- a/panel/chat/interface.py +++ b/panel/chat/interface.py @@ -584,7 +584,8 @@ def _serialize_for_transformers( messages: list[ChatMessage], role_names: dict[str, str | list[str]] | None = None, default_role: str | None = "assistant", - custom_serializer: Callable = None + custom_serializer: Callable = None, + **serialize_kwargs ) -> list[dict[str, Any]]: """ Exports the chat log for use with transformers. @@ -606,6 +607,8 @@ def _serialize_for_transformers( A custom function to format the ChatMessage's object. The function must accept one positional argument, the ChatMessage object, and return a string. If not provided, uses the serialize method on ChatMessage. + serialize_kwargs : dict + Additional keyword arguments to pass to the serializer. Returns ------- @@ -616,7 +619,8 @@ def _serialize_for_transformers( "user": [self.user], "assistant": [self.callback_user], } - return super()._serialize_for_transformers(messages, role_names, default_role, custom_serializer) + return super()._serialize_for_transformers( + messages, role_names, default_role, custom_serializer, **serialize_kwargs) @param.depends("_callback_state", watch=True) async def _update_input_disabled(self): @@ -644,7 +648,9 @@ def send( user: str | None = None, avatar: str | bytes | BytesIO | None = None, respond: bool = True, + **message_params ) -> ChatMessage | None: + """ Sends a value and creates a new message in the chat log. @@ -662,6 +668,8 @@ def send( Will default to the avatar parameter. respond : bool Whether to execute the callback. + message_params : dict + Additional parameters to pass to the ChatMessage. Returns ------- @@ -672,7 +680,7 @@ def send( user = self.user if avatar is None: avatar = self.avatar - return super().send(value, user=user, avatar=avatar, respond=respond) + return super().send(value, user=user, avatar=avatar, respond=respond, **message_params) def stream( self, @@ -706,6 +714,8 @@ def stream( The message to update. replace : bool Whether to replace the existing text when streaming a string or dict. + message_params : dict + Additional parameters to pass to the ChatMessage. Returns ------- diff --git a/panel/chat/message.py b/panel/chat/message.py index 4e9cdeeca3..67d8cc5c71 100644 --- a/panel/chat/message.py +++ b/panel/chat/message.py @@ -50,6 +50,7 @@ SYSTEM_LOGO = "⚙️" ERROR_LOGO = "❌" HELP_LOGO = "❓" +INPUT_LOGO = "❗" GPT_3_LOGO = "{dist_path}assets/logo/gpt-3.svg" GPT_4_LOGO = "{dist_path}assets/logo/gpt-4.svg" WOLFRAM_LOGO = "{dist_path}assets/logo/wolfram.svg" @@ -79,6 +80,7 @@ "exception": ERROR_LOGO, "error": ERROR_LOGO, "help": HELP_LOGO, + "input": INPUT_LOGO, # Human "adult": "🧑", "baby": "👶", @@ -179,7 +181,7 @@ class ChatMessage(Pane): header_objects = param.List(doc=""" A list of objects to display in the row of the header of the message.""") - max_width = param.Integer(default=1200, bounds=(0, None)) + max_width = param.Integer(default=1200, bounds=(0, None), allow_None=True) object = param.Parameter(allow_refs=False, doc=""" The message contents. Can be any Python object that panel can display.""") @@ -252,7 +254,7 @@ def __init__(self, object=None, **params): reaction_icons = params.get("reaction_icons", {"favorite": "heart"}) if isinstance(reaction_icons, dict): - params["reaction_icons"] = ChatReactionIcons(options=reaction_icons) + params["reaction_icons"] = ChatReactionIcons(options=reaction_icons, default_layout=Row) self._internal = True super().__init__(object=object, **params) self.chat_copy_icon = ChatCopyIcon( @@ -264,20 +266,16 @@ def __init__(self, object=None, **params): self._build_layout() def _build_layout(self): - self._activity_dot = HTML( - "●", - css_classes=["activity-dot"], - visible=self.param.show_activity_dot, - stylesheets=self._stylesheets + self.param.stylesheets.rx(), - ) + self._icon_divider = HTML(" | ", width=1, css_classes=["divider"]) + self._left_col = left_col = Column( self._render_avatar(), max_width=60, height=100, css_classes=["left"], - stylesheets=self._stylesheets + self.param.stylesheets.rx(), visible=self.param.show_avatar, sizing_mode=None, + stylesheets=self._stylesheets + self.param.stylesheets.rx(), ) self.param.watch(self._update_avatar_pane, "avatar") @@ -285,7 +283,6 @@ def _build_layout(self): self._update_chat_copy_icon() self._center_row = Row( self._object_panel, - self._render_reaction_icons(), css_classes=["center"], stylesheets=self._stylesheets + self.param.stylesheets.rx(), sizing_mode=None @@ -294,42 +291,64 @@ def _build_layout(self): self.param.watch(self._update_reaction_icons, "reaction_icons") self._user_html = HTML( - self.param.user, height=20, css_classes=["name"], - visible=self.param.show_user, stylesheets=self._stylesheets, + self.param.user, height=20, + css_classes=["name"], + visible=self.param.show_user, + ) + + self._activity_dot = HTML( + "●", + css_classes=["activity-dot"], + visible=self.param.show_activity_dot, ) - header_objects = ( - [self._user_html] + - self.param.header_objects.rx() + - [self.chat_copy_icon, self._activity_dot] + meta_row = Row( + self._user_html, + self._activity_dot, + sizing_mode="stretch_width", + css_classes=["meta"], + stylesheets=self._stylesheets + self.param.stylesheets.rx(), ) - header_row = Row( - objects=header_objects, + + header_col = Column( + objects=self.param.header_objects.rx(), + sizing_mode="stretch_width", + css_classes=["header"], stylesheets=self._stylesheets + self.param.stylesheets.rx(), + ) + + footer_col = Column( + objects=self.param.footer_objects.rx(), sizing_mode="stretch_width", - css_classes=["header"] + css_classes=["footer"], + stylesheets=self._stylesheets + self.param.stylesheets.rx(), ) self._timestamp_html = HTML( self.param.timestamp.rx().strftime(self.param.timestamp_format), css_classes=["timestamp"], - visible=self.param.show_timestamp + visible=self.param.show_timestamp, ) - footer_col = Column( - objects=self.param.footer_objects.rx() + [self._timestamp_html], - stylesheets=self._stylesheets + self.param.stylesheets.rx(), + self._icons_row = Row( + self.chat_copy_icon, + self._icon_divider, + self._render_reaction_icons(), + css_classes=["icons"], sizing_mode="stretch_width", - css_classes=["footer"], + stylesheets=self._stylesheets + self.param.stylesheets.rx(), ) self._right_col = right_col = Column( - header_row, + meta_row, + header_col, self._center_row, footer_col, + self._timestamp_html, + self._icons_row, css_classes=["right"], + sizing_mode=None, stylesheets=self._stylesheets + self.param.stylesheets.rx(), - sizing_mode=None ) viewable_params = { p: self.param[p] for p in self.param if p in Viewable.param @@ -487,7 +506,7 @@ def _create_panel(self, value, old=None): pass else: if isinstance(old, Markdown) and isinstance(value, str): - self._set_params(old, object=value) + self._set_params(old, enable_streaming=True, object=value) return old object_panel = _panel(value) @@ -536,7 +555,7 @@ def _render_reaction_icons(self): return reaction_icons def _update_reaction_icons(self, _): - self._center_row[1] = self._render_reaction_icons() + self._icons_row[-1] = self._render_reaction_icons() def _update(self, ref, old_models): """ @@ -579,9 +598,11 @@ def _update_chat_copy_icon(self): if isinstance(object_panel, str) and self.show_copy_icon: self.chat_copy_icon.value = object_panel self.chat_copy_icon.visible = True + self._icon_divider.visible = True else: self.chat_copy_icon.value = "" self.chat_copy_icon.visible = False + self._icon_divider.visible = False def _cleanup(self, root=None) -> None: """ diff --git a/panel/chat/step.py b/panel/chat/step.py index 22c9eb021a..a7384f13c2 100644 --- a/panel/chat/step.py +++ b/panel/chat/step.py @@ -127,7 +127,11 @@ def __init__(self, *objects, **params): self._title_pane, stylesheets=self._stylesheets + self.param.stylesheets.rx(), css_classes=["step-header"], - margin=(5, 0) + margin=(5, 0), + width=self.width, + max_width=self.max_width, + min_width=self.min_width, + sizing_mode=self.sizing_mode, ) def __enter__(self): diff --git a/panel/command/serve.py b/panel/command/serve.py index fe21ecbec3..cbd19b7eb8 100644 --- a/panel/command/serve.py +++ b/panel/command/serve.py @@ -3,6 +3,7 @@ ways. """ +import argparse import ast import base64 import logging @@ -279,10 +280,14 @@ def customize_applications(self, args, applications): applications['/'] = applications[f'/{index}'] return super().customize_applications(args, applications) - def warm_applications(self, applications, reuse_sessions): + def warm_applications(self, applications, reuse_sessions, error=True): from ..io.session import generate_session for path, app in applications.items(): - session = generate_session(app) + try: + session = generate_session(app) + except Exception as e: + if error: + raise e with set_curdoc(session.document): if config.session_key_func: reuse_sessions = False @@ -345,7 +350,6 @@ def customize_kwargs(self, args, server_kwargs): elif args.rest_provider is not None: raise ValueError(f"rest-provider {args.rest_provider!r} not recognized.") - config.autoreload = args.autoreload config.global_loading_spinner = args.global_loading_spinner config.reuse_sessions = args.reuse_sessions @@ -371,7 +375,7 @@ def customize_kwargs(self, args, server_kwargs): if args.autoreload: with record_modules(list(applications.values())): self.warm_applications( - applications, args.reuse_sessions + applications, args.reuse_sessions, error=False ) else: self.warm_applications(applications, args.reuse_sessions) @@ -638,7 +642,10 @@ def customize_kwargs(self, args, server_kwargs): return kwargs - def invoke(self, args): + def invoke(self, args: argparse.Namespace): + # Autoreload must be enabled before the application(s) are executed + # to avoid erroring out + config.autoreload = args.autoreload # Empty layout are valid and the Bokeh warning is silenced as usually # not relevant to Panel users. silence(EMPTY_LAYOUT, True) diff --git a/panel/custom.py b/panel/custom.py index 08c761ec63..6b19d9f137 100644 --- a/panel/custom.py +++ b/panel/custom.py @@ -153,7 +153,7 @@ async def _watch_esm(self): def _update_esm(self): esm = self._render_esm() - for ref, (model, _) in self._models.items(): + for ref, (model, _) in self._models.copy().items(): if esm == model.esm: continue self._apply_update({}, {'esm': esm}, model, ref) diff --git a/panel/dist/bundled/datatabulator/tabulator-tables@6.2.1/dist/css/tabulator_fast.min.css b/panel/dist/bundled/datatabulator/tabulator-tables@6.2.1/dist/css/tabulator_fast.min.css index b24f7beb87..6ded229fe5 100644 --- a/panel/dist/bundled/datatabulator/tabulator-tables@6.2.1/dist/css/tabulator_fast.min.css +++ b/panel/dist/bundled/datatabulator/tabulator-tables@6.2.1/dist/css/tabulator_fast.min.css @@ -488,7 +488,7 @@ background: rgba(255, 255, 255, 0.2); } .tabulator .tabulator-footer .tabulator-page.active { - color: var(--neutral-foreground-active); + color: var(--accent-foreground-active); } .tabulator .tabulator-footer .tabulator-page:disabled { opacity: 0.5; diff --git a/panel/dist/css/chat_copy_icon.css b/panel/dist/css/chat_copy_icon.css index 50b94ed15d..a03a6a1605 100644 --- a/panel/dist/css/chat_copy_icon.css +++ b/panel/dist/css/chat_copy_icon.css @@ -1,5 +1,3 @@ :host { - width: fit-content; - margin-block: 5px; - margin-inline: -6px; + margin-top: 5px; } diff --git a/panel/dist/css/chat_message.css b/panel/dist/css/chat_message.css index 3add5d9dba..4d75872094 100644 --- a/panel/dist/css/chat_message.css +++ b/panel/dist/css/chat_message.css @@ -7,13 +7,9 @@ } } -:host { +:host(.chat-message) { max-height: none; -} - -.chat-message { - display: flex; - flex-direction: row; + margin-top: 15px; } .avatar { @@ -21,8 +17,10 @@ flex-direction: column; justify-content: center; align-items: center; + align-self: center; - margin-top: calc(1em + 5px); + margin-top: 1em; + margin-bottom: 5px; min-width: 50px; width: 50px; min-height: 50px; @@ -40,11 +38,19 @@ 1px; } +.meta { + margin-bottom: -8px; +} + +.left { + display: flex; + flex-direction: column; +} + .right { display: flex; flex-direction: column; - margin-left: 5px; - max-width: calc(100% - 80px); + max-width: 100%; } .header { @@ -53,14 +59,11 @@ .name { font-size: 1em; - margin-bottom: 0px; - margin-top: 5px; } .center { width: calc(100% - 15px); /* Without this, words start on a new line */ - min-height: 4em; - margin-right: 10px; /* Space for reaction icons */ + min-height: 60px; padding: 0px; } @@ -73,9 +76,8 @@ 1px; font-size: 1.25em; min-height: 50px; - margin-top: 0px; + margin-block: 2px; margin-left: 10px; /* Space for avatar */ - margin-right: 5px; /* Space for reaction */ background-color: var(--panel-surface-color, #f1f1f1); min-width: 0; max-width: calc(100% - 40px); @@ -96,9 +98,13 @@ } .timestamp { - color: #a9a9a9; - display: flex; - margin-top: 0px; + opacity: 0.5; + transition: opacity 0.1s ease-in-out; + margin-block: 0px; +} + +.timestamp:hover { + opacity: 1; } .markdown { @@ -110,13 +116,13 @@ animation: icon-rotation 1.28s infinite cubic-bezier(0.68, -0.55, 0.27, 1.55); } -.reaction-icons { - display: flex; - flex-direction: column; - align-items: start; - width: fit-content; - margin-block: 0px; - margin-inline: 2px; +.icons { + opacity: 0.5; + transition: opacity 0.1s ease-in-out; +} + +.icons:hover { + opacity: 1; } @keyframes fadeOut { @@ -135,6 +141,18 @@ display: inline-block; animation: fadeOut 2s infinite cubic-bezier(0.68, -0.55, 0.27, 1.55); color: #32cd32; + /* since 1.25em, fix margins to everything is perceived to be more aligned */ font-size: 1.25em; - margin-block: 0px; + margin-left: -2.5px; + margin-top: 2px; + margin-bottom: 5px; +} + +.divider { + margin-right: 0px; + opacity: 0.2; +} + +.copy-icon { + margin-left: 10px; } diff --git a/panel/dist/css/chat_step.css b/panel/dist/css/chat_step.css index 334ec1d6a7..1aa8cedad0 100644 --- a/panel/dist/css/chat_step.css +++ b/panel/dist/css/chat_step.css @@ -16,11 +16,14 @@ } :host(.step-header) { - width: max-content; + width: fit-content; } .step-title { + width: calc(100% - 50px); font-size: 1.25em; + text-align: left; + overflow-wrap: break-word; } .step-message { @@ -32,8 +35,7 @@ .step-avatar-container { width: 15px; height: 15px; - margin-inline: 5px; - margin-block: 5px; + margin: 3px; } .step-avatar { diff --git a/panel/dist/css/chat_steps.css b/panel/dist/css/chat_steps.css new file mode 100644 index 0000000000..578c358ba4 --- /dev/null +++ b/panel/dist/css/chat_steps.css @@ -0,0 +1,26 @@ +.card-header { + border-bottom: 1px solid var(--panel-border-color, #e0e0e0); + padding-block: 15px; + margin-block: 5px; + box-shadow: + color-mix(in srgb, var(--panel-shadow-color) 30%, transparent) 0px 1px 2px + 0px, + color-mix(in srgb, var(--panel-shadow-color) 15%, transparent) 0px 1px 3px + 1px; + background-color: var(--panel-surface-color, #f1f1f1); +} + +.card-header:not(:hover) { + box-shadow: + color-mix(in srgb, var(--panel-shadow-color) 30%, transparent) 0px 1px 2px + 0px, + color-mix(in srgb, var(--panel-shadow-color) 15%, transparent) 0px 1px 3px + 1px; +} + +:host(.card-title) { + font-size: 1.25em; + padding-inline: 5px; + margin-left: 0px; + font-weight: 500; +} diff --git a/panel/dist/css/dataframe.css b/panel/dist/css/dataframe.css index d3e09bfd7b..1d7a0c8121 100644 --- a/panel/dist/css/dataframe.css +++ b/panel/dist/css/dataframe.css @@ -48,3 +48,27 @@ table.panel-df { overflow: auto; padding-right: 1px; } + +th[halign='left'] { + text-align: left; +} + +th[halign='left'] { + text-align: right; +} + +th[halign='center'] { + text-align: center; +} + +.panel-df.start-align td { + text-align: left; +} + +.panel-df.center-align td { + text-align: center; +} + +.panel-df.end-align td { + text-align: right; +} diff --git a/panel/dist/css/player.css b/panel/dist/css/player.css new file mode 100644 index 0000000000..6812c70dea --- /dev/null +++ b/panel/dist/css/player.css @@ -0,0 +1,11 @@ +.faster { + font-size: 11px; +} + +.slower { + font-size: 11px; +} + +.pn-player-value { + font-weight: bold; +} diff --git a/panel/io/application.py b/panel/io/application.py index ee8906ad56..ae3062c98c 100644 --- a/panel/io/application.py +++ b/panel/io/application.py @@ -136,7 +136,7 @@ def build_single_handler_application(path, argv=None): else: raise ValueError(f"Path for Bokeh server application does not exist: {path}") - if handler.failed: + if handler.failed and not config.autoreload: raise RuntimeError(f"Error loading {path}:\n\n{handler.error}\n{handler.error_detail} ") application = Application(handler) diff --git a/panel/io/cache.py b/panel/io/cache.py index c02556ec0d..8a806b879c 100644 --- a/panel/io/cache.py +++ b/panel/io/cache.py @@ -1,6 +1,8 @@ """ Implements memoization for functions with arbitrary arguments """ +from __future__ import annotations + import datetime as dt import functools import hashlib @@ -12,11 +14,14 @@ import sys import threading import time -import unittest import unittest.mock import weakref from contextlib import contextmanager +from typing import ( + TYPE_CHECKING, Any, Callable, Hashable, Literal, ParamSpec, Protocol, + TypeVar, overload, +) import param @@ -28,11 +33,22 @@ # Private API #--------------------------------------------------------------------- +if TYPE_CHECKING: + _P = ParamSpec("_P") + _R = TypeVar("_R") + _CallableT = TypeVar("_CallableT", bound=Callable) + + class _CachedFunc(Protocol[_CallableT]): + def clear(self, func_hashes: list[str | None]) -> None: + pass + + __call__: _CallableT + _CYCLE_PLACEHOLDER = b"panel-93KZ39Q-floatingdangeroushomechose-CYCLE" _FFI_TYPE_NAMES = ("_cffi_backend.FFI", "builtins.CompiledFFI",) -_HASH_MAP = {} +_HASH_MAP: dict[Hashable, str] = {} _HASH_STACKS = weakref.WeakKeyDictionary() @@ -305,11 +321,42 @@ def compute_hash(func, hash_funcs, args, kwargs): _HASH_MAP[key] = hash_value return hash_value +@overload +def cache( + func: Literal[None] = ..., + hash_funcs: dict[type[Any], Callable[[Any], bytes]] | None = ..., + max_items: int | None = ..., + policy: Literal['FIFO', 'LRU', 'LFU'] = ..., + ttl: float | None = ..., + to_disk: bool = ..., + cache_path: str | os.PathLike = ..., + per_session: bool = ..., +) -> Callable[[Callable[_P, _R]], _CachedFunc[Callable[_P, _R]]]: + ... + +@overload +def cache( + func: Callable[_P, _R], + hash_funcs: dict[type[Any], Callable[[Any], bytes]] | None = ..., + max_items: int | None = ..., + policy: Literal['FIFO', 'LRU', 'LFU'] = ..., + ttl: float | None = ..., + to_disk: bool = ..., + cache_path: str | os.PathLike = ..., + per_session: bool = ..., +) -> _CachedFunc[Callable[_P, _R]]: + ... def cache( - func=None, hash_funcs=None, max_items=None, policy='LRU', - ttl=None, to_disk=False, cache_path='./cache', per_session=False -): + func: Callable[_P, _R] | None = None, + hash_funcs: dict[type[Any], Callable[[Any], bytes]] | None = None, + max_items: int | None = None, + policy: Literal['FIFO', 'LRU', 'LFU'] = 'LRU', + ttl: float | None = None, + to_disk: bool = False, + cache_path: str | os.PathLike = './cache', + per_session: bool = False +) -> _CachedFunc[Callable[_P, _R]] | Callable[[Callable[_P, _R]], _CachedFunc[Callable[_P, _R]]]: """ Memoizes functions for a user session. Can be used as function annotation or just directly. @@ -336,7 +383,7 @@ def cache( the cache should not expire. The default is None. to_disk: bool Whether to cache to disk using diskcache. - cache_dir: str + cache_path: str Directory to cache to on disk. per_session: bool Whether to cache data only for the current session. @@ -348,15 +395,17 @@ def cache( hash_funcs = hash_funcs or {} if func is None: - return lambda f: cache( - func=f, - hash_funcs=hash_funcs, - max_items=max_items, - ttl=ttl, - to_disk=to_disk, - cache_path=cache_path, - per_session=per_session, - ) + def decorator(func: Callable[_P, _R]) -> _CachedFunc[Callable[_P, _R]]: + return cache( + func=func, + hash_funcs=hash_funcs, + max_items=max_items, + ttl=ttl, + to_disk=to_disk, + cache_path=cache_path, + per_session=per_session, + ) + return decorator func_hashes = [None] # noqa lock = threading.RLock() @@ -414,7 +463,7 @@ def hash_func(*args, **kwargs): if iscoroutinefunction(func): @functools.wraps(func) - async def wrapped_func(*args, **kwargs): + async def wrapped_func(*args: _P.args, **kwargs: _P.kwargs) -> _R: func_cache, hash_value, time = hash_func(*args, **kwargs) if hash_value in func_cache: with lock: @@ -427,7 +476,7 @@ async def wrapped_func(*args, **kwargs): return ret else: @functools.wraps(func) - def wrapped_func(*args, **kwargs): + def wrapped_func(*args: _P.args, **kwargs: _P.kwargs) -> _R: func_cache, hash_value, time = hash_func(*args, **kwargs) if hash_value in func_cache: with lock: @@ -452,7 +501,7 @@ def clear(func_hashes=func_hashes): cache = state._memoize_cache.get(func_hash, {}) cache.clear() - wrapped_func.clear = clear + wrapped_func.clear = clear # type: ignore[attr-defined] if per_session and state.curdoc and state.curdoc.session_context: def server_clear(session_context, clear=clear): diff --git a/panel/io/convert.py b/panel/io/convert.py index c5f1480e8d..18732bc0cd 100644 --- a/panel/io/convert.py +++ b/panel/io/convert.py @@ -41,8 +41,8 @@ PANEL_ROOT = pathlib.Path(__file__).parent.parent BOKEH_VERSION = base_version(bokeh.__version__) PY_VERSION = base_version(__version__) -PYODIDE_VERSION = 'v0.25.0' -PYSCRIPT_VERSION = '2024.2.1' +PYODIDE_VERSION = 'v0.26.2' +PYSCRIPT_VERSION = '2024.8.1' WHL_PATH = DIST_DIR / 'wheels' PANEL_LOCAL_WHL = WHL_PATH / f'panel-{__version__.replace("-dirty", "")}-py3-none-any.whl' BOKEH_LOCAL_WHL = WHL_PATH / f'bokeh-{BOKEH_VERSION}-py3-none-any.whl' @@ -272,7 +272,6 @@ def script_to_html( reqs = base_reqs + [ req for req in requirements if req not in ('panel', 'bokeh') ] - print(reqs) for name, min_version in MINIMUM_VERSIONS.items(): if any(name in req for req in reqs): reqs = [f'{name}>={min_version}' if name in req else req for req in reqs] @@ -290,17 +289,17 @@ def script_to_html( css_resources = [PYSCRIPT_CSS, PYSCRIPT_CSS_OVERRIDES] elif not css_resources: css_resources = [] - pyconfig = json.dumps({'packages': reqs, 'plugins': ["!error"]}) + pyconfig = json.dumps({'packages': reqs}) if 'worker' in runtime: plot_script = f'' web_worker = code else: - plot_script = f'{code}' + plot_script = f'' else: if css_resources == 'auto': css_resources = [] env_spec = ', '.join([repr(req) for req in reqs]) - code = code.encode("unicode_escape").decode("utf-8").replace('`', '\`') + code = code.encode("unicode_escape").decode("utf-8").replace('`', r'\`') if runtime == 'pyodide-worker': if js_resources == 'auto': js_resources = [] diff --git a/panel/io/datamodel.py b/panel/io/datamodel.py index 7a486553e0..1489b012b3 100644 --- a/panel/io/datamodel.py +++ b/panel/io/datamodel.py @@ -84,8 +84,8 @@ def class_selector_to_model(p, kwargs): return bp.Any(**kwargs) def bytes_param(p, kwargs): - kwargs.pop('default') - return bp.Bytes(**kwargs) + kwargs['default'] = None + return bp.Nullable(bp.Bytes, **kwargs) PARAM_MAPPING = { pm.Array: lambda p, kwargs: bp.Array(bp.Any, **kwargs), diff --git a/panel/io/document.py b/panel/io/document.py index 646152a7bb..dd0d8b629a 100644 --- a/panel/io/document.py +++ b/panel/io/document.py @@ -15,7 +15,7 @@ from contextlib import contextmanager from functools import partial, wraps from typing import ( - TYPE_CHECKING, Any, Callable, Iterator, Optional, + TYPE_CHECKING, Any, Callable, Iterator, ) from bokeh.application.application import SessionContext @@ -23,7 +23,7 @@ from bokeh.document.document import Document from bokeh.document.events import ( ColumnDataChangedEvent, ColumnsPatchedEvent, ColumnsStreamedEvent, - DocumentChangedEvent, ModelChangedEvent, + DocumentChangedEvent, MessageSentEvent, ModelChangedEvent, ) from bokeh.model.util import visit_immediate_value_references from bokeh.models import CustomJS @@ -36,6 +36,8 @@ if TYPE_CHECKING: from bokeh.core.has_props import HasProps + from bokeh.protocol.message import Message + from bokeh.server.connection import ServerConnection logger = logging.getLogger(__name__) @@ -45,16 +47,12 @@ DISPATCH_EVENTS = ( ColumnDataChangedEvent, ColumnsPatchedEvent, ColumnsStreamedEvent, - ModelChangedEvent + ModelChangedEvent, MessageSentEvent ) GC_DEBOUNCE = 5 -_WRITE_LOCK = None - -def WRITE_LOCK(): - global _WRITE_LOCK - if _WRITE_LOCK is None: - _WRITE_LOCK = asyncio.Lock() - return _WRITE_LOCK +_WRITE_FUTURES = weakref.WeakKeyDictionary() +_WRITE_MSGS = weakref.WeakKeyDictionary() +_WRITE_BLOCK = weakref.WeakKeyDictionary() _panel_last_cleanup = None _write_tasks = [] @@ -140,19 +138,19 @@ def _cleanup_doc(doc, destroy=True): # Destroy document doc.destroy(None) -async def _run_write_futures(futures): +async def _run_write_futures(doc): """ Ensure that all write_message calls are awaited and handled. """ from tornado.websocket import WebSocketClosedError - async with WRITE_LOCK(): - for future in futures: - try: - await future - except WebSocketClosedError: - logger.warning("Failed sending message as connection was closed") - except Exception as e: - logger.warning(f"Failed sending message due to following error: {e}") + futures = _WRITE_FUTURES.pop(doc, []) + for future in futures: + try: + await future + except WebSocketClosedError: + logger.warning("Failed sending message as connection was closed") + except Exception as e: + logger.warning(f"Failed sending message due to following error: {e}") def _dispatch_write_task(doc, func, *args, **kwargs): """ @@ -165,29 +163,44 @@ def _dispatch_write_task(doc, func, *args, **kwargs): except RuntimeError: doc.add_next_tick_callback(partial(func, *args, **kwargs)) -async def _dispatch_msgs(doc, msgs): +async def _dispatch_msgs(doc): """ Writes messages to a socket, ensuring that the write_lock is not set, otherwise re-schedules the write task on the event loop. """ from tornado.websocket import WebSocketHandler remaining = {} - for conn, msg in msgs.items(): + futures = [] + conn_msgs = _WRITE_MSGS.pop(doc, {}) + for conn, msgs in conn_msgs.items(): socket = conn._socket if hasattr(socket, 'write_lock') and socket.write_lock._block._value == 0: - remaining[conn] = msg + remaining[conn] = msgs continue - if isinstance(conn._socket, WebSocketHandler): - futures = dispatch_tornado(conn, msg=msg) - elif (socket_type:= type(conn._socket)) in extra_socket_handlers: - futures = extra_socket_handlers[socket_type](conn, msg=msg) + for msg in msgs: + if isinstance(conn._socket, WebSocketHandler): + futures += dispatch_tornado(conn, msg=msg) + elif (socket_type:= type(conn._socket)) in extra_socket_handlers: + futures += extra_socket_handlers[socket_type](conn, msg=msg) + else: + futures += dispatch_django(conn, msg=msg) + if futures: + if doc in _WRITE_FUTURES: + _WRITE_FUTURES[doc] += futures else: - futures = dispatch_django(conn, msg=msg) - await _run_write_futures(futures) + _WRITE_FUTURES[doc] = futures + await _run_write_futures(doc) if not remaining: + if doc in _WRITE_BLOCK: + del _WRITE_BLOCK[doc] return + for conn, msgs in remaining.items(): + if doc in _WRITE_MSGS: + _WRITE_MSGS[doc][conn] = msgs + _WRITE_MSGS[doc].get(conn, []) + else: + _WRITE_MSGS[doc] = {conn: msgs} await asyncio.sleep(0.01) - _dispatch_write_task(doc, _dispatch_msgs, doc, remaining) + _dispatch_write_task(doc, _dispatch_msgs, doc) def _garbage_collect(): if (new_time:= time.monotonic()-_panel_last_cleanup) < GC_DEBOUNCE: @@ -250,7 +263,60 @@ def _destroy_document(self, session): # Public API #--------------------------------------------------------------------- -def create_doc_if_none_exists(doc: Optional[Document]) -> Document: +def retrigger_events(doc: Document, events: list[DocumentChangedEvent]): + """ + Applies events that could not be processed previously. + """ + if doc.callbacks.hold_value: + doc.callbacks._held_events = events + list(doc.callbacks._held_events) + else: + _dispatch_events(doc, events) + +def write_events( + doc: Document, + connections: list[ServerConnection], + events: list[DocumentChangedEvent] +): + from tornado.websocket import WebSocketHandler + + futures = [] + for conn in connections: + if isinstance(conn._socket, WebSocketHandler): + futures += dispatch_tornado(conn, events) + elif (socket_type:= type(conn._socket)) in extra_socket_handlers: + futures += extra_socket_handlers[socket_type](conn, events) + else: + futures += dispatch_django(conn, events) + + if doc in _WRITE_FUTURES: + _WRITE_FUTURES[doc] += futures + else: + _WRITE_FUTURES[doc] = futures + + if state._unblocked(doc): + _dispatch_write_task(doc, _run_write_futures, doc) + else: + doc.add_next_tick_callback(partial(_run_write_futures, doc)) + +def schedule_write_events( + doc: Document, + connections: list[ServerConnection], + events: list[DocumentChangedEvent] +): + # Set up write locks + _WRITE_BLOCK[doc] = True + _WRITE_MSGS[doc] = msgs = _WRITE_MSGS.get(doc, {}) + # Create messages for remaining events + for conn in connections: + # Create a protocol message for any events that cannot be immediately dispatched + msg = conn.protocol.create('PATCH-DOC', events) + if conn in msgs: + msgs[conn].append(msg) + else: + msgs[conn] = [msg] + _dispatch_write_task(doc, _dispatch_msgs, doc) + +def create_doc_if_none_exists(doc: Document | None) -> Document: curdoc = doc or curdoc_locked() if curdoc is None: curdoc = Document() @@ -258,7 +324,7 @@ def create_doc_if_none_exists(doc: Optional[Document]) -> Document: curdoc = curdoc._doc return curdoc -def init_doc(doc: Optional[Document]) -> Document: +def init_doc(doc: Document | None) -> Document: curdoc = create_doc_if_none_exists(doc) if not curdoc.session_context: return curdoc @@ -312,7 +378,11 @@ def wrapper(*args, **kw): wrapper.lock = True # type: ignore return wrapper -def dispatch_tornado(conn, events=None, msg=None): +def dispatch_tornado( + conn: ServerConnection, + events: list[DocumentChangedEvent] | None = None, + msg: Message | None = None +): from tornado.websocket import WebSocketHandler socket = conn._socket ws_conn = getattr(socket, 'ws_connection', False) @@ -334,7 +404,11 @@ def dispatch_tornado(conn, events=None, msg=None): ]) return futures -def dispatch_django(conn, events=None, msg=None): +def dispatch_django( + conn: ServerConnection, + events: list[DocumentChangedEvent] | None = None, + msg: Message | None = None +): socket = conn._socket if msg is None: msg = conn.protocol.create('PATCH-DOC', events) @@ -379,77 +453,67 @@ def unlocked() -> Iterator: monkeypatch_events(curdoc.callbacks._held_events) return - from tornado.websocket import WebSocketHandler - connections = session._subscribed_connections - curdoc.hold() - events = None - remaining_events, dispatch_events = [], [] try: yield - locked = False + finally: + # Whether or not there was an error in the body of context manager + # we may have captured some events. We will dispatch these + # either by running the write futures, by serializing them + # as actual messages and scheduling these messages to be written, + # by having bokeh dispatch them on calling unhold or by + # scheduling them to be triggered later. + connections = session._subscribed_connections + locked = curdoc in _WRITE_MSGS or curdoc in _WRITE_BLOCK for conn in connections: socket = conn._socket if hasattr(socket, 'write_lock') and socket.write_lock._block._value == 0: locked = True break - events = curdoc.callbacks._held_events + remaining_events, writeable_events = [], [] + events = list(curdoc.callbacks._held_events or []) curdoc.callbacks._held_events = [] monkeypatch_events(events) for event in events: if isinstance(event, DISPATCH_EVENTS) and not locked: - dispatch_events.append(event) + writeable_events.append(event) else: remaining_events.append(event) - futures = [] - for conn in connections: - if not dispatch_events: - continue - elif isinstance(conn._socket, WebSocketHandler): - futures += dispatch_tornado(conn, dispatch_events) - elif (socket_type:= type(conn._socket)) in extra_socket_handlers: - futures += extra_socket_handlers[socket_type](conn, dispatch_events) - else: - futures += dispatch_django(conn, dispatch_events) - - if futures: - if state._unblocked(curdoc): - _dispatch_write_task(curdoc, _run_write_futures, futures) - else: - curdoc.add_next_tick_callback(partial(_run_write_futures, futures)) - except Exception as e: - # If we error out during the yield, there won't be any events - # captured so we end up simply calling curdoc.unhold() and - # raising the exception. If instead we error during event - # dispatch we restore the events in the order they were created - # and then let the finally section create a protocol message - # to dispatch the events, ensuring that the events which were - # marked for immediate dispatch are not lost. - if events is not None: + try: + if writeable_events: + write_events(curdoc, connections, writeable_events) + except Exception: remaining_events = events - raise e - finally: - # If for whatever reasons there are still events that couldn't - # be dispatched we create a protocol message for these immediately - # and then schedule a task to write the message to the websocket - # on the next iteration of the event loop. - if remaining_events: - # Separate serializable and non-serializable events - leftover_events = [e for e in remaining_events if not isinstance(e, Serializable)] - remaining_events = [e for e in remaining_events if isinstance(e, Serializable)] - - # Create messages for remaining events - msgs = {} - for conn in connections: - if not remaining_events: - continue - # Create a protocol message for any events that cannot be immediately dispatched - msgs[conn] = conn.protocol.create('PATCH-DOC', remaining_events) - _dispatch_write_task(curdoc, _dispatch_msgs, curdoc, msgs) - curdoc.callbacks._held_events += leftover_events - curdoc.unhold() + finally: + # If for whatever reasons there are still events that couldn't + # be dispatched we create a protocol message for these immediately + # and then schedule a task to write the message to the websocket + # on the next iteration of the event loop. This ensures that + # the message reflects the event at the time it was generated + # potentially avoiding issues serializing subsequent models + # which assume the serializer has previously seen them. + serializable_events = [e for e in remaining_events if isinstance(e, Serializable)] + held_events = [e for e in remaining_events if not isinstance(e, Serializable)] + if serializable_events: + try: + schedule_write_events(curdoc, connections, serializable_events) + except Exception: + # If the serialization fails we let bokeh handle them + held_events = remaining_events + curdoc.callbacks._held_events += held_events + + # Last we attempt to let bokeh handle these remaining events + # if this also fails we reapply the event at a later point in + # time. This should not happen but since network writes + # are fickle we handle this case anyway. + try: + retriggered_events = list(curdoc.callbacks._held_events) + curdoc.unhold() + except RuntimeError: + curdoc.add_next_tick_callback(partial(retrigger_events, curdoc, retriggered_events)) + @contextmanager def immediate_dispatch(doc: Document | None = None): diff --git a/panel/io/handlers.py b/panel/io/handlers.py index fec9ab2651..8b41319c1b 100644 --- a/panel/io/handlers.py +++ b/panel/io/handlers.py @@ -239,7 +239,7 @@ def autoreload_handle_exception(handler, module, e): alert_type='danger', margin=5, sizing_mode='stretch_width' ).servable() -def run_app(handler, module, doc, post_run=None): +def run_app(handler, module, doc, post_run=None, allow_empty=False): try: old_doc = curdoc() except RuntimeError: @@ -266,9 +266,25 @@ def post_check(): with patch_curdoc(doc): with profile_ctx(config.profiler) as sessions: with record_modules(handler=handler): - handler._runner.run(module, post_check) - if post_run: - post_run() + runner = handler._runner + if runner.error: + from ..pane import Alert + Alert( + f'{runner.error}\n
{runner.error_detail}
', + alert_type='danger', margin=5, sizing_mode='stretch_width' + ).servable() + else: + handler._runner.run(module, post_check) + if post_run: + post_run() + if not doc.roots and not allow_empty and config.autoreload and doc not in state._templates: + from ..pane import Alert + Alert( + ('Application did not publish any contents\n\n' + 'Ensure you have marked items as servable or added models to ' + 'the bokeh document manually.'), + alert_type='danger', margin=5, sizing_mode='stretch_width' + ).servable() finally: if config.profiler: try: @@ -422,6 +438,13 @@ def __init__(self, *, source: str, filename: PathLike, argv: list[str] = [], pac for f in PanelCodeHandler._io_functions: self._loggers[f] = self._make_io_logger(f) + def url_path(self) -> str | None: + if self.failed and not config.autoreload: + return None + + # TODO should fix invalid URL characters + return '/' + os.path.splitext(os.path.basename(self._runner.path))[0] + def modify_document(self, doc: 'Document'): if config.autoreload: path = self._runner.path @@ -433,14 +456,15 @@ def modify_document(self, doc: 'Document'): # If no module was returned it means the code runner has some permanent # unfixable problem, e.g. the configured source code has a syntax error - if module is None: + if module is None and not config.autoreload: return # One reason modules are stored is to prevent the module from being gc'd # before the document is. A symptom of a gc'd module is that its globals # become None. Additionally stored modules are used to provide correct # paths to custom models resolver. - doc.modules.add(module) + if module is not None: + doc.modules.add(module) run_app(self, module, doc) @@ -649,14 +673,15 @@ def modify_document(self, doc: Document) -> None: # If no module was returned it means the code runner has some permanent # unfixable problem, e.g. the configured source code has a syntax error - if module is None: + if module is None and not config.autoreload: return # One reason modules are stored is to prevent the module from being gc'd # before the document is. A symptom of a gc'd module is that its globals # become None. Additionally stored modules are used to provide correct # paths to custom models resolver. - doc.modules.add(module) + if module is not None: + doc.modules.add(module) def post_run(): if not (doc.roots or doc in state._templates or self._runner.error): @@ -665,7 +690,7 @@ def post_run(): with _patch_ipython_display(): with set_env_vars(MPLBACKEND='agg'): - run_app(self, module, doc, post_run) + run_app(self, module, doc, post_run, allow_empty=True) def _update_position_metadata(self, event): """ diff --git a/panel/io/ipywidget.py b/panel/io/ipywidget.py index 715ae09400..24be606f25 100644 --- a/panel/io/ipywidget.py +++ b/panel/io/ipywidget.py @@ -10,7 +10,7 @@ from bokeh.document.events import MessageSentEvent from bokeh.document.json import Literal, MessageSent, TypedDict from bokeh.util.serialization import make_id -from ipykernel.comm import Comm, CommManager +from ipykernel.comm import CommManager from ipykernel.kernelbase import Kernel from ipywidgets import Widget from ipywidgets._version import __protocol_version__ @@ -30,6 +30,11 @@ from ..util import classproperty from .state import set_curdoc, state +try: + from ipykernel.comm.comm import BaseComm as _IPyComm +except Exception: + from ipykernel.comm.comm import Comm as _IPyComm + try: # Support for ipywidgets>=8.0.5 import comm @@ -81,12 +86,12 @@ def _on_widget_constructed(widget, doc=None): 'metadata': { 'version': __protocol_version__ }, - 'kernel': kernel } if widget._model_id is not None: args['comm_id'] = widget._model_id try: - widget.comm = Comm(**args) + widget.comm = _IPyComm(**args) + widget.comm.kernel = kernel except Exception as e: if 'PANEL_IPYWIDGET' not in os.environ: raise e @@ -132,8 +137,9 @@ def generate(self, references, buffers): class PanelSessionWebsocket(SessionWebsocket): def __init__(self, *args, **kwargs): - session.Session.__init__(self, *args, **kwargs) + self.parent = kwargs.pop('parent', None) self._document = kwargs.pop('document', None) + session.Session.__init__(self, **kwargs) self._queue = [] self._document.on_message("ipywidgets_bokeh", self.receive) diff --git a/panel/io/jupyter_server_extension.py b/panel/io/jupyter_server_extension.py index 6d1d01dc35..d11a74e1a7 100644 --- a/panel/io/jupyter_server_extension.py +++ b/panel/io/jupyter_server_extension.py @@ -114,7 +114,7 @@ def get_server_root_dir(settings): app = r'{{ path }}' os.chdir(str(pathlib.Path(app).parent)) -sys.path = [os.getcwd()] + sys.path[1:] +sys.path = [os.getcwd()] + sys.path from panel.io.jupyter_executor import PanelExecutor executor = PanelExecutor(app, '{{ token }}', '{{ root_url }}') diff --git a/panel/io/profile.py b/panel/io/profile.py index e220b75f72..fb39f1d0c6 100644 --- a/panel/io/profile.py +++ b/panel/io/profile.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import io import os import re @@ -7,11 +9,20 @@ from contextlib import contextmanager from cProfile import Profile from functools import wraps +from typing import ( + TYPE_CHECKING, Callable, Iterator, Literal, ParamSpec, TypeVar, +) from ..config import config from ..util import escape from .state import state +if TYPE_CHECKING: + _P = ParamSpec("_P") + _R = TypeVar("_R") + +ProfilingEngine = Literal["pyinstrument", "snakeviz", "memray"] + def render_pyinstrument(sessions, timeline=False, show_all=False): from pyinstrument.renderers import HTMLRenderer @@ -183,7 +194,7 @@ def update_memray(*args): @contextmanager -def profile_ctx(engine='pyinstrument'): +def profile_ctx(engine: ProfilingEngine = 'pyinstrument') -> Iterator[list[Profile | bytes]]: """ A context manager which profiles the body of the with statement with the supplied profiling engine and returns the profiling object @@ -217,7 +228,7 @@ def profile_ctx(engine='pyinstrument'): tracker.__enter__() elif engine is None: pass - sessions = [] + sessions: list[Profile | bytes] = [] yield sessions if engine == 'pyinstrument': sessions.append(prof.stop()) @@ -230,7 +241,7 @@ def profile_ctx(engine='pyinstrument'): os.remove(tmp_file) -def profile(name, engine='pyinstrument'): +def profile(name: str, engine: ProfilingEngine = 'pyinstrument') -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: """ A decorator which may be added to any function to record profiling output. @@ -244,9 +255,9 @@ def profile(name, engine='pyinstrument'): """ if not isinstance(name, str): raise ValueError("Profiler must be given a name.") - def wrapper(func): + def wrapper(func: Callable[_P, _R]) -> Callable[_P, _R]: @wraps(func) - def wrapped(*args, **kwargs): + def wrapped(*args: _P.args, **kwargs: _P.kwargs) -> _R: if state.curdoc and state.curdoc in state._launching: return func(*args, **kwargs) with profile_ctx(engine) as sessions: diff --git a/panel/io/pyodide.py b/panel/io/pyodide.py index 58dab86054..4cbbd4c6ca 100644 --- a/panel/io/pyodide.py +++ b/panel/io/pyodide.py @@ -343,7 +343,8 @@ def pysync(event): return json_patch, buffer_map = _process_document_events(doc, [event]) json_patch = pyodide.ffi.to_js(json_patch, dict_converter=_dict_converter) - dispatch_fn(json_patch, pyodide.ffi.to_js(buffer_map), msg_id) + buffers = js.Map.new(pyodide.ffi.to_js(buffer_map)) + dispatch_fn(json_patch, buffers, msg_id) doc.on_change(pysync) doc.unhold() diff --git a/panel/io/state.py b/panel/io/state.py index 680e9f6c2c..5869e3c0f1 100644 --- a/panel/io/state.py +++ b/panel/io/state.py @@ -868,7 +868,7 @@ def dgen(): yield new.timestamp() elif callable(at): while True: - new = at(dt.datetime.utcnow()) + new = at(dt.datetime.now(dt.timezone.utc).replace(tzinfo=None)) if new is None: raise StopIteration yield new.replace(tzinfo=dt.timezone.utc).astimezone().timestamp() diff --git a/panel/layout/float.py b/panel/layout/float.py index af945905b8..7740b4fe67 100644 --- a/panel/layout/float.py +++ b/panel/layout/float.py @@ -34,6 +34,14 @@ class FloatPanel(ListLike, ReactiveHTML): """ Float provides a floating panel layout. + + Reference: https://panel.holoviz.org/reference/layouts/FloatPanel.html + + :Example: + + >>> import panel as pn + >>> pn.extension("floatpanel") + >>> pn.layout.FloatPanel("**I can float**!", position="center", width=300).servable() """ config = param.Dict({}, doc=""" diff --git a/panel/models/ace.py b/panel/models/ace.py index 391abac0f2..b898a6b363 100644 --- a/panel/models/ace.py +++ b/panel/models/ace.py @@ -50,6 +50,10 @@ def __js_skip__(cls): code = String(default='') + code_input = String(default='') + + on_keyup = Bool(default=True) + theme = Enum(ace_themes, default='chrome') filename = Nullable(String()) diff --git a/panel/models/ace.ts b/panel/models/ace.ts index e65cd9c229..584e3fc544 100644 --- a/panel/models/ace.ts +++ b/panel/models/ace.ts @@ -68,7 +68,20 @@ export class AcePlotView extends HTMLBoxView { this._update_language() this._editor.setReadOnly(this.model.readonly) this._editor.setShowPrintMargin(this.model.print_margin) - this._editor.on("change", () => this._update_code_from_editor()) + // if on keyup, update code from editor + if (this.model.on_keyup) { + this._editor.on("change", () => this._update_code_from_editor()) + } else { + this._editor.on("blur", () => this._update_code_from_editor()) + this._editor.commands.addCommand({ + name: "updateCodeFromEditor", + bindKey: {win: "Ctrl-Enter", mac: "Command-Enter"}, + exec: () => { + this._update_code_from_editor() + }, + }) + } + this._editor.on("change", () => this._update_code_input_from_editor()) } _update_code_from_model(): void { @@ -87,6 +100,12 @@ export class AcePlotView extends HTMLBoxView { } } + _update_code_input_from_editor(): void { + if (this._editor.getValue() != this.model.code_input) { + this.model.code_input = this._editor.getValue() + } + } + _update_theme(): void { this._editor.setTheme(`ace/theme/${this.model.theme}`) } @@ -120,6 +139,8 @@ export namespace AcePlot { export type Attrs = p.AttrsOf export type Props = HTMLBox.Props & { code: p.Property + code_input: p.Property + on_keyup: p.Property language: p.Property filename: p.Property theme: p.Property @@ -145,6 +166,8 @@ export class AcePlot extends HTMLBox { this.define(({Any, List, Bool, Str, Nullable}) => ({ code: [ Str, "" ], + code_input: [ Str, "" ], + on_keyup: [ Bool, true ], filename: [ Nullable(Str), null], language: [ Str, "" ], theme: [ Str, "chrome" ], diff --git a/panel/models/card.ts b/panel/models/card.ts index 9ba534efa8..01f4654912 100644 --- a/panel/models/card.ts +++ b/panel/models/card.ts @@ -86,8 +86,7 @@ export class CardView extends ColumnView { this.button_el.style.backgroundColor = header_background != null ? header_background : "" header.el.style.backgroundColor = header_background != null ? header_background : "" this.button_el.appendChild(header.el) - - this.button_el.onclick = () => this._toggle_button() + this.button_el.addEventListener("click", (e: MouseEvent) => this._toggle_button(e)) header_el = this.button_el } else { header_el = DOM.create_element((header_tag as any), {class: header_css_classes}) @@ -120,7 +119,12 @@ export class CardView extends ColumnView { this.invalidate_layout() } - _toggle_button(): void { + _toggle_button(e: MouseEvent): void { + for (const path of e.composedPath()) { + if (path instanceof HTMLInputElement) { + return + } + } this.model.collapsed = !this.model.collapsed } diff --git a/panel/models/comm_manager.ts b/panel/models/comm_manager.ts index fd1f046d4c..939c0421fc 100644 --- a/panel/models/comm_manager.ts +++ b/panel/models/comm_manager.ts @@ -52,7 +52,7 @@ export class CommManager extends Model { this._blocked = false this._timeout = Date.now() if (((window as any).PyViz == undefined) || (!(window as any).PyViz.comm_manager)) { - console.log("Could not find comm manager on window.PyViz, ensure the extension is loaded.") + console.warn("Could not find comm manager on window.PyViz, ensure the extension is loaded.") } else { this.ns = (window as any).PyViz this.ns.comm_manager.register_target(this.plot_id, this.comm_id, (msg: any) => { @@ -163,9 +163,10 @@ export class CommManager extends Model { this._blocked = false } if ((metadata.msg_type == "Ready") && metadata.content) { + // eslint-disable-next-line no-console console.log("Python callback returned following output:", metadata.content) } else if (metadata.msg_type == "Error") { - console.log("Python failed with the following traceback:", metadata.traceback) + console.warn("Python failed with the following traceback:", metadata.traceback) } } @@ -176,9 +177,10 @@ export class CommManager extends Model { const plot_id = this.plot_id if ((metadata.msg_type == "Ready")) { if (metadata.content) { + // eslint-disable-next-line no-console console.log("Python callback returned following output:", metadata.content) } else if (metadata.msg_type == "Error") { - console.log("Python failed with the following traceback:", metadata.traceback) + console.warn("Python failed with the following traceback:", metadata.traceback) } } else if (plot_id != null) { let plot = null diff --git a/panel/models/deckgl.ts b/panel/models/deckgl.ts index 289c3e317d..7486dbbec7 100644 --- a/panel/models/deckgl.ts +++ b/panel/models/deckgl.ts @@ -20,7 +20,6 @@ function extractClasses() { classesDict[cls] = deck[cls] } const carto = (window as any).CartoLibrary - console.log(carto) const layers = Object.keys(carto.CARTO_LAYERS).filter(x => x.endsWith("Layer")) for (const layer of layers) { classesDict[layer] = carto.CARTO_LAYERS[layer] diff --git a/panel/models/discrete_player.ts b/panel/models/discrete_player.ts new file mode 100644 index 0000000000..97cb31b3d1 --- /dev/null +++ b/panel/models/discrete_player.ts @@ -0,0 +1,46 @@ +import type * as p from "@bokehjs/core/properties" +import {PlayerView, Player} from "./player" +import {span} from "@bokehjs/core/dom" +import {to_string} from "@bokehjs/core/util/pretty" + +export class DiscretePlayerView extends PlayerView { + declare model: DiscretePlayer + + override append_value_to_title_el(): void { + let label = this.model.options[this.model.value] + if (typeof label !== "string") { + label = to_string(label) + } + this.titleEl.appendChild(span({class: "pn-player-value"}, label)) + } +} + +export namespace DiscretePlayer { + export type Attrs = p.AttrsOf + export type Props = Player.Props & { + options: p.Property + } +} + +export interface DiscretePlayer extends DiscretePlayer.Attrs { } + +export class DiscretePlayer extends Player { + + declare properties: DiscretePlayer.Props + + constructor(attrs?: Partial) { + super(attrs) + } + + static override __module__ = "panel.models.widgets" + + static { + this.prototype.default_view = DiscretePlayerView + + this.define(({List, Any}) => ({ + options: [List(Any), []], + })) + + this.override({width: 400}) + } +} diff --git a/panel/models/feed.ts b/panel/models/feed.ts index 9767779c8e..51a5194b6d 100644 --- a/panel/models/feed.ts +++ b/panel/models/feed.ts @@ -30,6 +30,8 @@ export class FeedView extends ColumnView { _last_visible: UIElementView | null _rendered: boolean = false _sync: boolean + _reference: number | null = null + _reference_view: UIElementView | null = null override initialize(): void { super.initialize() @@ -87,17 +89,64 @@ export class FeedView extends ColumnView { override async update_children(): Promise { const last = this._last_visible const scroll_top = this.el.scrollTop - const before_offset = last?.el.offsetTop || 0 + this._reference_view = last + this._reference = last?.el.offsetTop || 0 this._sync = false - await super.update_children() - this._sync = true - requestAnimationFrame(() => { - const after_offset = last?.el.offsetTop || 0 - const offset = (after_offset-before_offset) - if (offset > 0) { - this.el.scrollTo({top: scroll_top + offset, behavior: "instant"}) + const created = await this.build_child_views() + const created_children = new Set(created) + const createdLength = created.length + const views_length = this.child_views.length + + // Check whether we simply have to prepend or append items + // instead of removing and reordering them + const is_prepended = created.every((view, index) => view === this.child_views[index]) + const is_appended = created.every((view, index) => view === this.child_views[views_length - createdLength + index]) + const reorder = !(is_prepended || is_appended) + if (reorder) { + // First remove and then either reattach existing elements or render and + // attach new elements, so that the order of children is consistent, while + // avoiding expensive re-rendering of existing views. + for (const child_view of this.child_views) { + child_view.el.remove() } - }) + } + const prepend: Element[] = [] + for (const child_view of this.child_views) { + const is_new = created_children.has(child_view) + const target = this.shadow_el + if (reorder) { + if (is_new) { + child_view.render_to(target) + } else { + target.append(child_view.el) + } + } else { + if (is_new) { + child_view.render() + if (is_appended) { + target.append(child_view.el) + } else if (is_prepended) { + prepend.push(child_view.el) + } + } + } + } + if (is_prepended) { + this.shadow_el.prepend(...prepend) + } + this.r_after_render() + this._update_children() + this.invalidate_layout() + this._sync = true + + // Ensure we adjust the scroll position in case we prepended items + if (is_prepended) { + requestAnimationFrame(() => { + const after_offset = this._reference_view?.el.offsetTop || 0 + const offset = (after_offset-(this._reference || 0)) + this.el.scrollTo({top: scroll_top + offset, behavior: "smooth"}) + }) + } } override async build_child_views(): Promise { diff --git a/panel/models/html.ts b/panel/models/html.ts index ec0d2b4800..fe549519e2 100644 --- a/panel/models/html.ts +++ b/panel/models/html.ts @@ -1,4 +1,4 @@ -import {ModelEvent} from "@bokehjs/core/bokeh_events" +import {ModelEvent, server_event} from "@bokehjs/core/bokeh_events" import type * as p from "@bokehjs/core/properties" import type {Attrs, Dict} from "@bokehjs/core/types" import {entries} from "@bokehjs/core/util/object" @@ -6,6 +6,25 @@ import {Markup} from "@bokehjs/models/widgets/markup" import {PanelMarkupView} from "./layout" import {serializeEvent} from "./event-to-object" +@server_event("html_stream") +export class HTMLStreamEvent extends ModelEvent { + constructor(readonly model: HTML, readonly patch: string, readonly start: number) { + super() + this.patch = patch + this.start = start + this.origin = model + } + + protected override get event_values(): Attrs { + return {model: this.origin, patch: this.patch, start: this.start} + } + + static override from_values(values: object) { + const {model, patch, start} = values as {model: HTML, patch: string, start: number} + return new HTMLStreamEvent(model, patch, start) + } +} + export class DOMEvent extends ModelEvent { constructor(readonly node: string, readonly data: unknown) { super() @@ -39,8 +58,33 @@ export function run_scripts(node: Element): void { } } +function throttle(func: Function, limit: number): any { + let lastFunc: number + let lastRan: number + + return function(...args: any) { + // @ts-ignore + const context = this + + if (!lastRan) { + func.apply(context, args) + lastRan = Date.now() + } else { + clearTimeout(lastFunc) + + lastFunc = setTimeout(function() { + if ((Date.now() - lastRan) >= limit) { + func.apply(context, args) + lastRan = Date.now() + } + }, limit - (Date.now() - lastRan)) + } + } +} + export class HTMLView extends PanelMarkupView { declare model: HTML + _buffer: string | null = null protected readonly _event_listeners: Map void>> = new Map() @@ -49,6 +93,7 @@ export class HTMLView extends PanelMarkupView { const {text, visible, events} = this.model.properties this.on_change(text, () => { + this._buffer = null const html = this.process_tex() this.set_html(html) }) @@ -61,6 +106,19 @@ export class HTMLView extends PanelMarkupView { this._remove_event_listeners() this._setup_event_listeners() }) + + const set_text = throttle(() => { + const text = this._buffer + this._buffer = null + this.model.setv({text}, {silent: true}) + const html = this.process_tex() + this.set_html(html) + }, 10) + this.model.on_event(HTMLStreamEvent, (event: HTMLStreamEvent) => { + const beginning = this._buffer == null ? this.model.text : this._buffer + this._buffer = beginning.slice(0, event.start)+event.patch + set_text() + }) } protected rerender() { diff --git a/panel/models/index.ts b/panel/models/index.ts index 73b1da3770..013635d924 100644 --- a/panel/models/index.ts +++ b/panel/models/index.ts @@ -15,6 +15,7 @@ export {CustomMultiSelect} from "./multiselect" export {DataTabulator} from "./tabulator" export {DatetimePicker} from "./datetime_picker" export {DeckGLPlot} from "./deckgl" +export {DiscretePlayer} from "./discrete_player" export {ECharts} from "./echarts" export {Feed} from "./feed" export {FileDownload} from "./file_download" diff --git a/panel/models/markup.py b/panel/models/markup.py index 69b0d40385..c0299c504c 100644 --- a/panel/models/markup.py +++ b/panel/models/markup.py @@ -1,12 +1,28 @@ """ Custom bokeh Markup models. """ +from typing import Any + from bokeh.core.properties import ( Bool, Dict, Either, Float, Int, List, Null, String, ) +from bokeh.events import ModelEvent from bokeh.models.widgets import Markup +class HTMLStreamEvent(ModelEvent): + + event_name = 'html_stream' + + def __init__(self, model, patch=None, start=None): + self.patch = patch + self.start = start + super().__init__(model=model) + + def event_values(self) -> dict[str, Any]: + return dict(super().event_values(), patch=self.patch, start=self.start) + + class HTML(Markup): """ A bokeh model to render HTML markup including embedded script tags. diff --git a/panel/models/player.ts b/panel/models/player.ts index 94eb0435c6..8a95462471 100644 --- a/panel/models/player.ts +++ b/panel/models/player.ts @@ -1,59 +1,19 @@ import {Enum} from "@bokehjs/core/kinds" import type * as p from "@bokehjs/core/properties" -import {div} from "@bokehjs/core/dom" +import {div, empty, span} from "@bokehjs/core/dom" import {Widget, WidgetView} from "@bokehjs/models/widgets/widget" +import {to_string} from "@bokehjs/core/util/pretty" const SVG_STRINGS = { - slower: '', - first: '\ - ', - previous: ' \ - ', - reverse: '', - pause: '', - play: '', - next: ' \ - ', - last: '', - faster: '', + slower: '', + first: '', + previous: '', + reverse: '', + pause: '', + play: '', + next: '', + last: '', + faster: '', } function press(btn_list: HTMLButtonElement[]): void { @@ -68,6 +28,7 @@ export class PlayerView extends WidgetView { declare model: Player protected buttonEl: HTMLDivElement + protected titleEl: HTMLDivElement protected groupEl: HTMLDivElement protected sliderEl: HTMLInputElement protected loop_state: HTMLFormElement @@ -77,10 +38,22 @@ export class PlayerView extends WidgetView { protected _toggle_play: CallableFunction protected _changing: boolean = false + protected slower: HTMLButtonElement + protected first: HTMLButtonElement + protected previous: HTMLButtonElement + protected reverse: HTMLButtonElement + protected pause: HTMLButtonElement + protected play: HTMLButtonElement + protected next: HTMLButtonElement + protected last: HTMLButtonElement + protected faster: HTMLButtonElement + override connect_signals(): void { super.connect_signals() - const {direction, value, loop_policy, disabled, show_loop_controls} = this.model.properties + const {title, value_align, direction, value, loop_policy, disabled, show_loop_controls, show_value, scale_buttons, visible_buttons, visible_loop_options} = this.model.properties + this.on_change(title, () => this.update_title_and_value()) + this.on_change(value_align, () => this.set_value_align()) this.on_change(direction, () => this.set_direction()) this.on_change(value, () => this.render()) this.on_change(loop_policy, () => this.set_loop_state(this.model.loop_policy)) @@ -92,6 +65,10 @@ export class PlayerView extends WidgetView { this.groupEl.removeChild(this.loop_state) } }) + this.on_change(show_value, () => this.update_title_and_value()) + this.on_change(scale_buttons, () => this.update_css()) + this.on_change(visible_buttons, () => this.update_css()) + this.on_change(visible_loop_options, () => this.update_css()) } toggle_disable() { @@ -113,6 +90,56 @@ export class PlayerView extends WidgetView { return 250 } + update_css(): void { + const button_style_small = `text-align: center; flex-grow: 1; margin: 2px; transform: scale(${this.model.scale_buttons}); max-width: 50px;` + const button_style = `text-align: center; flex-grow: 2; margin: 2px; transform: scale(${this.model.scale_buttons}); max-width: 50px;` + + const buttons = { + slower: this.slower, + first: this.first, + previous: this.previous, + reverse: this.reverse, + pause: this.pause, + play: this.play, + next: this.next, + last: this.last, + faster: this.faster, + } + + for (const [name, button] of Object.entries(buttons)) { + if (button) { + if (this.model.visible_buttons.includes(name)) { + button.style.display = "" // Reset to default display + if (name === "slower" || name === "faster") { + button.style.cssText += button_style_small + } else { + button.style.cssText += button_style + } + } else { + button.style.display = "none" // Hide the button completely + } + } + } + + for (const el of this.loop_state.children) { + if (el.tagName.toLowerCase() == "input") { + const anyEl = el as any + if (this.model.visible_loop_options.includes(anyEl.value)) { + anyEl.style.display = "" + } else { + anyEl.style.display = "none" + } + } else if (el.tagName.toLowerCase() == "label") { + const anyEl = el as any + if (this.model.visible_loop_options.includes(anyEl.innerHTML.toLowerCase())) { + anyEl.style.display = "" + } else { + anyEl.style.display = "none" + } + } + } + } + override render(): void { if (this.sliderEl == null) { super.render() @@ -127,7 +154,13 @@ export class PlayerView extends WidgetView { this.groupEl = div() this.groupEl.style.display = "flex" this.groupEl.style.flexDirection = "column" - this.groupEl.style.alignItems = "center" + + // Display Value + this.titleEl = div() + this.titleEl.classList.add("pn-player-title") + this.titleEl.style.padding = "0 5px 0 5px" + this.update_title_and_value() + this.set_value_align() // Slider this.sliderEl = document.createElement("input") @@ -146,77 +179,74 @@ export class PlayerView extends WidgetView { // Buttons const button_div = div() as any this.buttonEl = button_div - button_div.style.cssText = "margin: 0 auto; display: flex; padding: 5px; align-items: stretch; width: 100%;" - - const button_style_small = "text-align: center; min-width: 20px; flex-grow: 1; margin: 2px" - const button_style = "text-align: center; min-width: 40px; flex-grow: 2; margin: 2px" - - const slower = document.createElement("button") - slower.style.cssText = button_style_small - slower.innerHTML = SVG_STRINGS.slower - slower.onclick = () => this.slower() - button_div.appendChild(slower) - - const first = document.createElement("button") - first.style.cssText = button_style - first.innerHTML = SVG_STRINGS.first - first.onclick = () => this.first_frame() - button_div.appendChild(first) - - const previous = document.createElement("button") - previous.style.cssText = button_style - previous.innerHTML = SVG_STRINGS.previous - previous.onclick = () => this.previous_frame() - button_div.appendChild(previous) - - const reverse = document.createElement("button") - reverse.style.cssText = button_style - reverse.innerHTML = SVG_STRINGS.reverse - reverse.onclick = () => this.reverse_animation() - button_div.appendChild(reverse) - - const pause = document.createElement("button") - pause.style.cssText = button_style - pause.innerHTML = SVG_STRINGS.pause - pause.onclick = () => this.pause_animation() - button_div.appendChild(pause) - - const play = document.createElement("button") - play.style.cssText = button_style - play.innerHTML = SVG_STRINGS.play - play.onclick = () => this.play_animation() - button_div.appendChild(play) - - const next = document.createElement("button") - next.style.cssText = button_style - next.innerHTML = SVG_STRINGS.next - next.onclick = () => this.next_frame() - button_div.appendChild(next) - - const last = document.createElement("button") - last.style.cssText = button_style - last.innerHTML = SVG_STRINGS.last - last.onclick = () => this.last_frame() - button_div.appendChild(last) - - const faster = document.createElement("button") - faster.style.cssText = button_style_small - faster.innerHTML = SVG_STRINGS.faster - faster.onclick = () => this.faster() - button_div.appendChild(faster) + button_div.style.cssText = "margin: 0 auto; display: flex; padding: 5px; align-items: stretch; justify-content: center; width: 100%;" + + this.slower = document.createElement("button") + this.slower.classList.add("slower") + this.slower.innerHTML = SVG_STRINGS.slower + this.slower.onclick = () => this.slower_speed() + button_div.appendChild(this.slower) + + this.first = document.createElement("button") + this.first.classList.add("first") + this.first.innerHTML = SVG_STRINGS.first + this.first.onclick = () => this.first_frame() + button_div.appendChild(this.first) + + this.previous = document.createElement("button") + this.previous.classList.add("previous") + this.previous.innerHTML = SVG_STRINGS.previous + this.previous.onclick = () => this.previous_frame() + button_div.appendChild(this.previous) + + this.reverse = document.createElement("button") + this.reverse.classList.add("reverse") + this.reverse.innerHTML = SVG_STRINGS.reverse + this.reverse.onclick = () => this.reverse_animation() + button_div.appendChild(this.reverse) + + this.pause = document.createElement("button") + this.pause.classList.add("pause") + this.pause.innerHTML = SVG_STRINGS.pause + this.pause.onclick = () => this.pause_animation() + button_div.appendChild(this.pause) + + this.play = document.createElement("button") + this.play.classList.add("play") + this.play.innerHTML = SVG_STRINGS.play + this.play.onclick = () => this.play_animation() + button_div.appendChild(this.play) + + this.next = document.createElement("button") + this.next.classList.add("next") + this.next.innerHTML = SVG_STRINGS.next + this.next.onclick = () => this.next_frame() + button_div.appendChild(this.next) + + this.last = document.createElement("button") + this.last.classList.add("last") + this.last.innerHTML = SVG_STRINGS.last + this.last.onclick = () => this.last_frame() + button_div.appendChild(this.last) + + this.faster = document.createElement("button") + this.faster.classList.add("faster") + this.faster.innerHTML = SVG_STRINGS.faster + this.faster.onclick = () => this.faster_speed() + button_div.appendChild(this.faster) // toggle this._toggle_reverse = () => { - unpress([pause, play]) - press([reverse]) + unpress([this.pause, this.play]) + press([this.reverse]) } this._toogle_pause = () => { - unpress([reverse, play]) - press([pause]) + unpress([this.reverse, this.play]) + press([this.pause]) } this._toggle_play = () => { - unpress([reverse, pause]) - press([play]) + unpress([this.reverse, this.pause]) + press([this.play]) } // Loop control @@ -224,26 +254,32 @@ export class PlayerView extends WidgetView { this.loop_state.style.cssText = "margin: 0 auto; display: table" const once = document.createElement("input") + once.classList.add("once") once.type = "radio" once.value = "once" once.name = "state" const once_label = document.createElement("label") once_label.innerHTML = "Once" + once_label.classList.add("once-label") once_label.style.cssText = "padding: 0 10px 0 5px; user-select:none;" const loop = document.createElement("input") + loop.classList.add("loop") loop.setAttribute("type", "radio") loop.setAttribute("value", "loop") loop.setAttribute("name", "state") const loop_label = document.createElement("label") + loop_label.classList.add("loop-label") loop_label.innerHTML = "Loop" loop_label.style.cssText = "padding: 0 10px 0 5px; user-select:none;" const reflect = document.createElement("input") + reflect.classList.add("reflect") reflect.setAttribute("type", "radio") reflect.setAttribute("value", "reflect") reflect.setAttribute("name", "state") const reflect_label = document.createElement("label") + loop_label.classList.add("reflect-label") reflect_label.innerHTML = "Reflect" reflect_label.style.cssText = "padding: 0 10px 0 5px; user-select:none;" @@ -263,6 +299,7 @@ export class PlayerView extends WidgetView { this.loop_state.appendChild(reflect) this.loop_state.appendChild(reflect_label) + this.groupEl.appendChild(this.titleEl) this.groupEl.appendChild(this.sliderEl) this.groupEl.appendChild(button_div) if (this.model.show_loop_controls) { @@ -270,11 +307,13 @@ export class PlayerView extends WidgetView { } this.toggle_disable() + this.update_css() this.shadow_el.appendChild(this.groupEl) } set_frame(frame: number, throttled: boolean = true): void { this.model.value = frame + this.update_title_and_value() if (throttled) { this.model.value_throttled = frame } @@ -294,6 +333,55 @@ export class PlayerView extends WidgetView { return "once" } + update_title_and_value(): void { + empty(this.titleEl) + + const hide_header = this.model.title == null || (this.model.title.length == 0 && !this.model.show_value) + this.titleEl.style.display = hide_header ? "none" : "" + + if (!hide_header) { + this.titleEl.style.visibility = "visible" + const {title} = this.model + if (title != null && title.length > 0) { + if (this.contains_tex_string(title)) { + this.titleEl.innerHTML = `${this.process_tex(title)}` + if (this.model.show_value) { + this.titleEl.innerHTML += ": " + } + } else { + this.titleEl.textContent = `${title}` + if (this.model.show_value) { + this.titleEl.textContent += ": " + } + } + } + + if (this.model.show_value) { + this.append_value_to_title_el() + } + } else { + this.titleEl.style.visibility = "hidden" + } + } + + append_value_to_title_el(): void { + this.titleEl.appendChild(span({class: "pn-player-value"}, to_string(this.model.value))) + } + + set_value_align(): void { + switch (this.model.value_align) { + case "start": + this.titleEl.style.textAlign = "left" + break + case "center": + this.titleEl.style.textAlign = "center" + break + case "end": + this.titleEl.style.textAlign = "right" + break + } + } + set_loop_state(state: string): void { const button_group = this.loop_state.state for (let i = 0; i < button_group.length; i++) { @@ -320,8 +408,17 @@ export class PlayerView extends WidgetView { this.set_frame(this.model.end) } - slower(): void { + updateSpeedButton(button: HTMLButtonElement, interval: number, originalSVG: string): void { + const fps = 1000 / interval + button.innerHTML = `${fps.toFixed(1)}
fps` + setTimeout(() => { + button.innerHTML = originalSVG + }, this.model.preview_duration) // Show for 1.5 seconds + } + + slower_speed(): void { this.model.interval = Math.round(this.model.interval / 0.7) + this.updateSpeedButton(this.slower, this.model.interval, SVG_STRINGS.slower) if (this.model.direction > 0) { this.play_animation() } else if (this.model.direction < 0) { @@ -329,8 +426,9 @@ export class PlayerView extends WidgetView { } } - faster(): void { + faster_speed(): void { this.model.interval = Math.round(this.model.interval * 0.7) + this.updateSpeedButton(this.faster, this.model.interval, SVG_STRINGS.faster) if (this.model.direction > 0) { this.play_animation() } else if (this.model.direction < 0) { @@ -429,9 +527,17 @@ export namespace Player { end: p.Property step: p.Property loop_policy: p.Property + title: p.Property value: p.Property + value_align: p.Property value_throttled: p.Property + preview_duration: p.Property show_loop_controls: p.Property + show_value: p.Property + button_scale: p.Property + scale_buttons: p.Property + visible_buttons: p.Property + visible_loop_options: p.Property } } @@ -450,16 +556,24 @@ export class Player extends Widget { static { this.prototype.default_view = PlayerView - this.define(({Bool, Int}) => ({ + this.define(({Bool, Int, Float, List, Str}) => ({ direction: [Int, 0], interval: [Int, 500], start: [Int, 0], end: [Int, 10], step: [Int, 1], loop_policy: [LoopPolicy, "once"], + title: [Str, ""], value: [Int, 0], + value_align: [Str, "start"], value_throttled: [Int, 0], + preview_duration: [Int, 1500], show_loop_controls: [Bool, true], + show_value: [Bool, true], + button_scale: [Float, 1], + scale_buttons: [Float, 1], + visible_buttons: [List(Str), ["slower", "first", "previous", "reverse", "pause", "play", "next", "last", "faster"]], + visible_loop_options: [List(Str), ["once", "loop", "reflect"]], })) this.override({width: 400}) diff --git a/panel/models/react_component.ts b/panel/models/react_component.ts index 5ba1661838..ce97971653 100644 --- a/panel/models/react_component.ts +++ b/panel/models/react_component.ts @@ -29,17 +29,17 @@ export class ReactComponentView extends ReactiveESMView { 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() -}` + 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"` @@ -49,15 +49,15 @@ ${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}` + 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} @@ -73,7 +73,8 @@ class Child extends React.Component { } get element() { - return this.view.el + const view = this.view + return view == null ? null : view.el } componentDidMount() { @@ -89,8 +90,17 @@ class Child extends React.Component { }) } + append_child(ref) { + if (ref != null) { + const view = this.view + if (view != null) { + ref.appendChild(this.element) + } + } + } + render() { - return React.createElement('div', {className: "child-wrapper", ref: (ref) => ref && ref.appendChild(this.element)}) + return React.createElement('div', {className: "child-wrapper", ref: (ref) => this.append_child(ref)}) } } @@ -187,10 +197,14 @@ class Component extends React.Component { } } -const props = {view, model: react_proxy, data: view.model.data, el: view.container} -let rendered = React.createElement(Component, props) +function render() { + const props = {view, model: react_proxy, data: view.model.data, el: view.container} + let rendered = React.createElement(Component, props) + + ${render_code} +} -${render_code}` +export default {render}` } } diff --git a/panel/models/reactive_esm.ts b/panel/models/reactive_esm.ts index 9bcdf0d67e..e600a6d884 100644 --- a/panel/models/reactive_esm.ts +++ b/panel/models/reactive_esm.ts @@ -146,8 +146,10 @@ export class ReactiveESMView extends HTMLBoxView { accessed_children: string[] = [] compiled_module: any = null model_proxy: any + render_module: Promise | null = null _changing: boolean = false _child_callbacks: Map void)[]> + _child_rendered: Map = new Map() _event_handlers: ((event: ESMEvent) => void)[] = [] _lifecycle_handlers: Map void)[]> = new Map([ ["after_render", []], @@ -155,6 +157,7 @@ export class ReactiveESMView extends HTMLBoxView { ["remove", []], ]) _rendered: boolean = false + _stale_children: boolean = false override initialize(): void { super.initialize() @@ -256,6 +259,7 @@ export class ReactiveESMView extends HTMLBoxView { this._apply_visible() this._child_callbacks = new Map() + this._child_rendered.clear() this._rendered = false set_size(this.el, this.model) @@ -265,6 +269,12 @@ export class ReactiveESMView extends HTMLBoxView { if (this.model.compile_error) { this.render_error(this.model.compile_error) } else { + const code = this._render_code() + const render_url = URL.createObjectURL( + new Blob([code], {type: "text/javascript"}), + ) + // @ts-ignore + this.render_module = importShim(render_url) this.render_esm() } } @@ -273,16 +283,20 @@ export class ReactiveESMView extends HTMLBoxView { 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 -}) +function render() { + 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() -})` + Promise.resolve(output).then((out) => { + if (out instanceof Element) { + view.container.replaceChildren(out) + } + view.after_rendered() + }) +} + +export default {render}` } after_rendered(): void { @@ -291,12 +305,12 @@ Promise.resolve(output).then((out) => { cb() } this.render_children() - this.model_proxy.on(this.accessed_children, () => this.render_esm()) + this.model_proxy.on(this.accessed_children, () => { this._stale_children = true }) this._rendered = true } render_esm(): void { - if (this.model.compiled === null) { + if (this.model.compiled === null || this.render_module === null) { return } this.accessed_properties = [] @@ -304,12 +318,7 @@ Promise.resolve(output).then((out) => { (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) + this.render_module.then((mod: any) => mod.default.render()) } render_children() { @@ -322,12 +331,13 @@ Promise.resolve(output).then((out) => { continue } const parent = view.el.parentNode - if (parent) { + if (parent && !this._child_rendered.has(view)) { view.render() - view.after_render() + this._child_rendered.set(view, true) } } } + this.r_after_render() } override remove(): void { @@ -335,6 +345,8 @@ Promise.resolve(output).then((out) => { for (const cb of (this._lifecycle_handlers.get("remove") || [])) { cb() } + this._child_callbacks.clear() + this._child_rendered.clear() } override after_resize(): void { @@ -362,7 +374,8 @@ Promise.resolve(output).then((out) => { override async update_children(): Promise { const created_children = new Set(await this.build_child_views()) - for (const child_view of this.child_views) { + const all_views = this.child_views + for (const child_view of all_views) { child_view.el.remove() } @@ -374,13 +387,21 @@ Promise.resolve(output).then((out) => { const child = this._lookup_child(child_view) if (!child) { continue - } else if (new_views.has(child)) { + } + + if (new_views.has(child)) { new_views.get(child).push(child_view) } else { new_views.set(child, [child_view]) } } + for (const view of this._child_rendered.keys()) { + if (!all_views.includes(view)) { + this._child_rendered.delete(view) + } + } + for (const child of this.model.children) { const callbacks = this._child_callbacks.get(child) || [] const new_children = new_views.get(child) || [] @@ -388,7 +409,10 @@ Promise.resolve(output).then((out) => { callback(new_children) } } - + if (this._stale_children) { + this.render_esm() + this._stale_children = false + } this._update_children() this.invalidate_layout() } @@ -504,8 +528,12 @@ export class ReactiveESM extends HTMLBox { protected _declare_importmap(): void { if (this.importmap) { const importMap = {...this.importmap} - // @ts-ignore - importShim.addImportMap(importMap) + try { + // @ts-ignore + importShim.addImportMap(importMap) + } catch (e) { + console.warn(`Failed to add import map: ${e}`) + } } } diff --git a/panel/models/reactive_html.ts b/panel/models/reactive_html.ts index 25d0227023..eda843f2ba 100644 --- a/panel/models/reactive_html.ts +++ b/panel/models/reactive_html.ts @@ -200,7 +200,7 @@ export class ReactiveHTMLView extends HTMLBoxView { const script_fn = this._script_fns.get(property) if (script_fn === undefined) { if (!silent) { - console.log(`Script '${property}' could not be found.`) + console.warn(`Script '${property}' could not be found.`) } return } @@ -532,7 +532,7 @@ export class ReactiveHTMLView extends HTMLBoxView { this._changing = true this.model.data.setv(serialize_attrs(attrs)) } catch { - console.log("Could not serialize", attrs) + console.error("Could not serialize", attrs) } finally { this._changing = false } diff --git a/panel/models/speech_to_text.ts b/panel/models/speech_to_text.ts index b2636523be..67fca6b326 100644 --- a/panel/models/speech_to_text.ts +++ b/panel/models/speech_to_text.ts @@ -86,12 +86,10 @@ export class SpeechToTextView extends HTMLBoxView { this.model.results = serializeResults(event.results) } this.recognition.onerror = (event: any) => { - console.log("SpeechToText Error") - console.log(event) + console.error(`SpeechToText Error: ${event}`) } this.recognition.onnomatch = (event: any) => { - console.log("SpeechToText No Match") - console.log(event) + console.warn(`SpeechToText No Match: ${event}`) } this.recognition.onaudiostart = () => this.model.audio_started = true diff --git a/panel/models/tabulator.py b/panel/models/tabulator.py index 586c42895e..22eb3a7f7f 100644 --- a/panel/models/tabulator.py +++ b/panel/models/tabulator.py @@ -155,7 +155,7 @@ class DataTabulator(HTMLBox): page = Nullable(Int) - page_size = Int() + page_size = Nullable(Int) max_page = Int() diff --git a/panel/models/tabulator.ts b/panel/models/tabulator.ts index 3ca6601bbc..aaa613fe5d 100644 --- a/panel/models/tabulator.ts +++ b/panel/models/tabulator.ts @@ -302,6 +302,30 @@ const datetimeEditor = function(cell: any, onRendered: any, success: any, cancel return input } +function find_column(group: any, field: string): any { + if (group.columns != null) { + for (const col of group.columns) { + const found = find_column(col, field) + if (found) { + return found + } + } + } else { + return group.field === field ? group : null + } +} + +function clone_column(group: any): any { + if (group.columns == null) { + return {...group} + } + const group_columns = [] + for (const col of group.columns) { + group_columns.push(clone_column(col)) + } + return {...group, columns: group_columns} +} + export class DataTabulatorView extends HTMLBoxView { declare model: DataTabulator @@ -312,13 +336,15 @@ export class DataTabulatorView extends HTMLBoxView { _updating_page: boolean = false _updating_sort: boolean = false _selection_updating: boolean = false + _last_selected_row: any = null _initializing: boolean _lastVerticalScrollbarTopPosition: number = 0 _lastHorizontalScrollbarLeftPosition: number = 0 _applied_styles: boolean = false _building: boolean = false _debounced_redraw: any = null - _restore_scroll: boolean = false + _restore_scroll: boolean | "horizontal" | "vertical" = false + _updating_scroll: boolean = false override connect_signals(): void { super.connect_signals() @@ -363,7 +389,7 @@ export class DataTabulatorView extends HTMLBoxView { for (const row of this.tabulator.rowManager.getRows()) { if (row.cells.length > 0) { const index = row.data._index - const icon = this.model.expanded.indexOf(index) < 0 ? "►" : "▼" + const icon = this.model.expanded.includes(index) ? "▼" : "►" row.cells[1].element.innerText = icon } } @@ -395,18 +421,23 @@ export class DataTabulatorView extends HTMLBoxView { if (this.tabulator === undefined) { return } + this._restore_scroll = "horizontal" this._selection_updating = true + this._updating_scroll = true this.setData() + this._updating_scroll = false this._selection_updating = false this.postUpdate() }) this.connect(this.model.source.streaming, () => this.addData()) this.connect(this.model.source.patching, () => { const inds = this.model.source.selected.indices + this._updating_scroll = true this.updateOrAddData() - this.record_scroll() + this._updating_scroll = false // Restore indices since updating data may have reset checkbox column this.model.source.selected.indices = inds + this.restore_scroll() }) this.connect(this.model.source.selected.change, () => this.setSelection()) this.connect(this.model.source.selected.properties.indices.change, () => this.setSelection()) @@ -569,8 +600,10 @@ export class DataTabulatorView extends HTMLBoxView { if (rows.length === 0) { this.tabulator.rowManager.renderEmptyScroll() } - // Ensure that after filtering the page is updated - this.updatePage(this.tabulator.getPage()) + if (this.model.pagination != null) { + // Ensure that after filtering the page is updated + this.updatePage(this.tabulator.getPage()) + } }) this.tabulator.on("pageLoaded", (pageno: number) => { this.updatePage(pageno) @@ -590,7 +623,7 @@ export class DataTabulatorView extends HTMLBoxView { } if (this.model.pagination !== "remote") { this._updating_sort = true - this.model.sorters = sorts + this.model.sorters = sorts.reverse() this._updating_sort = false } }) @@ -603,6 +636,31 @@ export class DataTabulatorView extends HTMLBoxView { this.setStyles() if (this.model.pagination) { + if (this.model.page_size == null) { + const table = this.shadow_el.querySelector(".tabulator-table") + const holder = this.shadow_el.querySelector(".tabulator-tableholder") + if (table != null && holder != null) { + const table_height = holder.clientHeight + let height = 0 + let page_size = null + const heights = [] + for (let i = 0; i table_height) { + page_size = i + break + } + } + if (height < table_height) { + page_size = table.children.length + const remaining = table_height - height + page_size += Math.floor(remaining / Math.min(...heights)) + } + this.model.page_size = Math.max(page_size || 1, 1) + } + } this.setMaxPage() this.tabulator.setPage(this.model.page) } @@ -665,7 +723,7 @@ export class DataTabulatorView extends HTMLBoxView { layout: this.getLayout(), pagination: this.model.pagination != null, paginationMode: this.model.pagination, - paginationSize: this.model.page_size, + paginationSize: this.model.page_size || 20, paginationInitialPage: 1, groupBy: this.groupBy, rowFormatter: (row: any) => this._render_row(row), @@ -709,16 +767,24 @@ export class DataTabulatorView extends HTMLBoxView { const new_children = await this.build_child_views() resolve(new_children) }).then((new_children) => { - for (const r of this.model.expanded) { - const row = this.tabulator.getRow(r) + const rows = this.tabulator.getRows() + const lookup = new Map() + for (const row of rows) { const index = row._row?.data._index - if (this.model.children.get(index) == null) { + if (index != null) { + lookup.set(index, row) + } + } + for (const index of this.model.expanded) { + if (!this.model.children.has(index)) { continue } + const row = lookup.get(index) const model = this.model.children.get(index) const view = model == null ? null : this._child_views.get(model) - if ((view != null) && (new_children as UIElementView[]).includes(view)) { - this._render_row(row, false) + if (view != null) { + const render = (new_children as UIElementView[]).includes(view) + this._render_row(row, false, render) } } this._update_children() @@ -729,7 +795,7 @@ export class DataTabulatorView extends HTMLBoxView { }) } - _render_row(row: any, resize: boolean = true): void { + _render_row(row: any, resize: boolean = true, render: boolean = true): void { const index = row._row?.data._index if (!this.model.expanded.includes(index) || this.model.children.get(index) == null) { return @@ -746,7 +812,7 @@ export class DataTabulatorView extends HTMLBoxView { const viewEl = div({style: {background_color: bg, margin_left: neg_margin, max_width: "100%", overflow_x: "hidden"}}) viewEl.appendChild(view.el) rowEl.appendChild(viewEl) - if (!view.has_finished()) { + if (!view.has_finished() && render) { view.render() view.after_render() } @@ -760,16 +826,16 @@ export class DataTabulatorView extends HTMLBoxView { _expand_render(cell: any): string { const index = cell._cell.row.data._index const icon = this.model.expanded.indexOf(index) < 0 ? "►" : "▼" - return `${icon}` + return icon } _update_expand(cell: any): void { const index = cell._cell.row.data._index const expanded = [...this.model.expanded] - const exp_index = expanded.indexOf(index) - if (exp_index < 0) { + if (!expanded.includes(index)) { expanded.push(index) } else { + const exp_index = expanded.indexOf(index) const removed = expanded.splice(exp_index, 1)[0] const model = this.model.children.get(removed) if (model != null) { @@ -780,7 +846,7 @@ export class DataTabulatorView extends HTMLBoxView { } } this.model.expanded = expanded - if (expanded.indexOf(index) < 0) { + if (!expanded.includes(index)) { return } let ready = true @@ -810,13 +876,8 @@ export class DataTabulatorView extends HTMLBoxView { columns.push({field: "_index", frozen: true, visible: false}) if (config_columns != null) { for (const column of config_columns) { - if (column.columns != null) { - const group_columns = [] - for (const col of column.columns) { - group_columns.push({...col}) - } - columns.push({...column, columns: group_columns}) - } else if (column.formatter === "expand") { + const new_column = clone_column(column) + if (column.formatter === "expand") { const expand = { hozAlign: "center", cellClick: (_: any, cell: any) => { @@ -830,7 +891,6 @@ export class DataTabulatorView extends HTMLBoxView { } columns.push(expand) } else { - const new_column = {...column} if (new_column.formatter === "rowSelection") { new_column.cellClick = (_: any, cell: any) => { cell.getRow().toggleSelect() @@ -844,18 +904,8 @@ export class DataTabulatorView extends HTMLBoxView { let tab_column: any = null if (config_columns != null) { for (const col of columns) { - if (col.columns != null) { - for (const c of col.columns) { - if (column.field === c.field) { - tab_column = c - break - } - } - if (tab_column != null) { - break - } - } else if (column.field === col.field) { - tab_column = col + tab_column = find_column(col, column.field) + if (tab_column != null) { break } } @@ -1002,16 +1052,15 @@ export class DataTabulatorView extends HTMLBoxView { } // Update table - - setData(): void { + setData(): Promise { if (this._initializing || this._building || !this.tabulator.initialized) { - return + return Promise.resolve(undefined) } const data = this.getData() if (this.model.pagination != null) { - this.tabulator.rowManager.setData(data, true, false) + return this.tabulator.rowManager.setData(data, true, false) } else { - this.tabulator.setData(data) + return this.tabulator.setData(data) } } @@ -1019,9 +1068,20 @@ export class DataTabulatorView extends HTMLBoxView { const rows = this.tabulator.rowManager.getRows() const last_row = rows[rows.length-1] const start = ((last_row?.data._index) || 0) - this.setData() - if (this.model.follow && last_row) { - this.tabulator.scrollToRow(start, "top", false) + this._updating_page = true + const promise = this.setData() + if (this.model.follow) { + promise.then(() => { + if (this.model.pagination) { + this.tabulator.setPage(Math.ceil(this.tabulator.rowManager.getDataCount() / (this.model.page_size || 20))) + } + if (last_row) { + this.tabulator.scrollToRow(start, "top", false) + } + this._updating_page = false + }) + } else { + this._updating_page = true } } @@ -1029,7 +1089,9 @@ export class DataTabulatorView extends HTMLBoxView { this.setSelection() this.setStyles() if (this._restore_scroll) { - this.restore_scroll() + const vertical = this._restore_scroll === "horizontal" ? false : true + const horizontal = this._restore_scroll === "vertical" ? false : true + this.restore_scroll(horizontal, vertical) this._restore_scroll = false } } @@ -1066,7 +1128,7 @@ export class DataTabulatorView extends HTMLBoxView { } updatePage(pageno: number): void { - if (this.model.pagination === "local" && this.model.page !== pageno) { + if (this.model.pagination === "local" && this.model.page !== pageno && !this._updating_page) { this._updating_page = true this.model.page = pageno this._updating_page = false @@ -1177,18 +1239,30 @@ export class DataTabulatorView extends HTMLBoxView { this._selection_updating = false } - restore_scroll(): void { - const opts = { - top: this._lastVerticalScrollbarTopPosition, - left: this._lastHorizontalScrollbarLeftPosition, - behavior: "instant", + restore_scroll(horizontal: boolean=true, vertical: boolean=true): void { + if (!(horizontal || vertical)) { + return + } + const opts: ScrollToOptions = {behavior: "instant"} + if (vertical) { + opts.top = this._lastVerticalScrollbarTopPosition + } + if (horizontal) { + opts.left = this._lastHorizontalScrollbarLeftPosition } - setTimeout(() => this.tabulator.rowManager.element.scrollTo(opts), 0) + setTimeout(() => { + this._updating_scroll = true + this.tabulator.rowManager.element.scrollTo(opts) + this._updating_scroll = false + }, 0) } // Update model record_scroll() { + if (this._updating_scroll) { + return + } this._lastVerticalScrollbarTopPosition = this.tabulator.rowManager.element.scrollTop this._lastHorizontalScrollbarLeftPosition = this.tabulator.rowManager.element.scrollLeft } @@ -1208,44 +1282,25 @@ export class DataTabulatorView extends HTMLBoxView { const selected = this.model.source.selected const index: number = row._row.data._index - if (this.model.pagination === "remote") { - const includes = this.model.source.selected.indices.indexOf(index) == -1 - const flush = !(e.ctrlKey || e.metaKey || e.shiftKey) - if (e.shiftKey && selected.indices.length) { - const start = selected.indices[selected.indices.length-1] - if (index>start) { - for (let i = start; i<=index; i++) { - indices.push(i) - } - } else { - for (let i = start; i>=index; i--) { - indices.push(i) - } - } - } else { - indices.push(index) - } - this._selection_updating = true - this.model.trigger_event(new SelectionEvent(indices, includes, flush)) - this._selection_updating = false - return - } - if (e.ctrlKey || e.metaKey) { - indices = [...this.model.source.selected.indices] - } else if (e.shiftKey && selected.indices.length) { - const start = selected.indices[selected.indices.length-1] - if (index>start) { - for (let i = start; iindex; i--) { - indices.push(i) - } + indices = [...selected.indices] + } else if (e.shiftKey && this._last_selected_row) { + const rows = row._row.parent.getDisplayRows() + const start_idx = rows.indexOf(this._last_selected_row) + if (start_idx !== -1) { + const end_idx = rows.indexOf(row._row) + const reverse = start_idx > end_idx + const [start, end] = reverse ? [end_idx+1, start_idx+1] : [start_idx, end_idx] + indices = rows.slice(start, end).map((r: any) => r.data._index) + if (reverse) { indices = indices.reverse() } } } - if (indices.indexOf(index) < 0) { + const flush = !(e.ctrlKey || e.metaKey || e.shiftKey) + const includes = indices.includes(index) + const remote = this.model.pagination === "remote" + + // Toggle the index on or off (if remote we let Python do the toggling) + if (!includes || remote) { indices.push(index) } else { indices.splice(indices.indexOf(index), 1) @@ -1257,10 +1312,16 @@ export class DataTabulatorView extends HTMLBoxView { } } const filtered = this._filter_selected(indices) - this.tabulator.deselectRow() - this.tabulator.selectRow(filtered) + if (!remote) { + this.tabulator.deselectRow() + this.tabulator.selectRow(filtered) + } + this._last_selected_row = row._row this._selection_updating = true - selected.indices = filtered + if (!remote) { + selected.indices = filtered + } + this.model.trigger_event(new SelectionEvent(indices, !includes, flush)) this._selection_updating = false } @@ -1351,7 +1412,7 @@ export namespace DataTabulator { layout: p.Property max_page: p.Property page: p.Property - page_size: p.Property + page_size: p.Property pagination: p.Property select_mode: p.Property selectable_rows: p.Property @@ -1397,7 +1458,7 @@ export class DataTabulator extends HTMLBox { max_page: [ Float, 0 ], pagination: [ Nullable(Str), null ], page: [ Float, 0 ], - page_size: [ Float, 0 ], + page_size: [ Nullable(Float), null ], select_mode: [ Any, true ], selectable_rows: [ Nullable(List(Float)), null ], source: [ Ref(ColumnDataSource) ], diff --git a/panel/models/widgets.py b/panel/models/widgets.py index d35d9c298f..615255f55a 100644 --- a/panel/models/widgets.py +++ b/panel/models/widgets.py @@ -31,6 +31,9 @@ class Player(Widget): """ The Player widget provides controls to play through a number of frames. """ + title = Nullable(String, default="", help=""" + The slider's label (supports :ref:`math text `). + """) start = Int(0, help="Lower bound of the Player slider") @@ -40,6 +43,9 @@ class Player(Widget): value_throttled = Int(0, help="Current throttled value of the player app") + value_align = String("start", help="""Location to display + the value of the slider ("start" "center", "end")""") + step = Int(1, help="Number of steps to advance the player by.") interval = Int(500, help="Interval between updates") @@ -53,10 +59,32 @@ class Player(Widget): show_loop_controls = Bool(True, help="""Whether the loop controls radio buttons are shown""") + preview_duration = Int(1500, help=""" + Duration (in milliseconds) for showing the current FPS when clicking + the slower/faster buttons, before reverting to the icon.""") + + show_value = Bool(True, help=""" + Whether to show the widget value""") + width = Override(default=400) height = Override(default=250) + scale_buttons = Float(1, help="Percentage to scale the size of the buttons by") + + visible_buttons = List(String, default=[ + 'slower', 'first', 'previous', 'reverse', 'pause', 'play', 'next', 'last', 'faster' + ], help="The buttons to display on the player.") + + visible_loop_options = List(String, default=[ + 'once', 'loop', 'reflect' + ], help="The loop options to display on the player.") + +class DiscretePlayer(Player): + + options = List(Any, help=""" + List of discrete options.""") + class SingleSelect(InputWidget): ''' Single-select widget. diff --git a/panel/package-lock.json b/panel/package-lock.json index 9eae014520..3b49cf4b41 100644 --- a/panel/package-lock.json +++ b/panel/package-lock.json @@ -1,15 +1,15 @@ { "name": "@holoviz/panel", - "version": "1.5.0-b.2", + "version": "1.5.0-b.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@holoviz/panel", - "version": "1.5.0-b.2", + "version": "1.5.0-b.5", "license": "BSD-3-Clause", "dependencies": { - "@bokeh/bokehjs": "3.5.0", + "@bokeh/bokehjs": "3.5.1", "@types/debounce": "^1.2.0", "@types/gl-matrix": "^2.4.5", "ace-code": "^1.24.1", @@ -41,9 +41,9 @@ } }, "node_modules/@bokeh/bokehjs": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@bokeh/bokehjs/-/bokehjs-3.5.0.tgz", - "integrity": "sha512-9BdydcclXPmEIq8C12+d44HZZgxLooseugdHi/uo0Rd7Xgmvqs3SfjjtgpHKYCfzcI20v0XgjdMhjiwmq068hw==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@bokeh/bokehjs/-/bokehjs-3.5.1.tgz", + "integrity": "sha512-mxtmZxRppVUydble18UsFcff5HYUTsqhZMqGLVjE09eTak7cqRQXfF4v94EqwSaCwwkad6vg0kl0lF+rY9BiXw==", "workspaces": [ "./make", "./src/compiler", diff --git a/panel/package.json b/panel/package.json index bc7896f379..a3e45944e4 100644 --- a/panel/package.json +++ b/panel/package.json @@ -1,6 +1,6 @@ { "name": "@holoviz/panel", - "version": "1.5.0-b.2", + "version": "1.5.0-b.5", "description": "The powerful data exploration & web app framework for Python.", "license": "BSD-3-Clause", "repository": { @@ -8,7 +8,7 @@ "url": "https://github.com/holoviz/panel.git" }, "dependencies": { - "@bokeh/bokehjs": "3.5.0", + "@bokeh/bokehjs": "3.5.1", "@types/debounce": "^1.2.0", "@types/gl-matrix": "^2.4.5", "ace-code": "^1.24.1", diff --git a/panel/pane/_textual.py b/panel/pane/_textual.py index a1af9781a7..33e34c210f 100644 --- a/panel/pane/_textual.py +++ b/panel/pane/_textual.py @@ -77,7 +77,12 @@ def disable_input(self): def start_application_mode(self): self._size_watcher = self._terminal.param.watch(self._resize, ['nrows', 'ncols']) - self._parser = XTermParser(lambda: False, self._debug) + try: + # Textual < 0.76 + self._parser = XTermParser(lambda: False, debug=self._debug) + except TypeError: + # Textual >= 0.76 + self._parser = XTermParser(debug=self._debug) self._input_watcher = self._terminal.param.watch(self._process_input, 'value') def stop_application_mode(self): diff --git a/panel/pane/base.py b/panel/pane/base.py index 5a58256933..7a7c149633 100644 --- a/panel/pane/base.py +++ b/panel/pane/base.py @@ -420,7 +420,7 @@ def _update_object( view._preprocess(root, self) def _update_pane(self, *events) -> None: - for ref, (_, parent) in self._models.items(): + for ref, (_, parent) in self._models.copy().items(): if ref not in state._views or ref in state._fake_roots: continue viewable, root, doc, comm = state._views[ref] diff --git a/panel/pane/echarts.py b/panel/pane/echarts.py index 8c9e50c3ae..7a2d4384d5 100644 --- a/panel/pane/echarts.py +++ b/panel/pane/echarts.py @@ -151,7 +151,7 @@ def on_event(self, event: str, callback: Callable, query: str | None = None): """ self._py_callbacks[event][query].append(callback) event_config = {event: list(queries) for event, queries in self._py_callbacks.items()} - for ref, (model, _) in self._models.items(): + for ref, (model, _) in self._models.copy().items(): self._apply_update({}, {'event_config': event_config}, model, ref) def js_on_event(self, event: str, callback: str | CustomJS, query: str | None = None, **args): @@ -176,7 +176,7 @@ def js_on_event(self, event: str, callback: str | CustomJS, query: str | None = of the object. """ self._js_callbacks[event].append((query, callback, args)) - for ref, (model, _) in self._models.items(): + for ref, (model, _) in self._models.copy().items(): js_events = self._get_js_events(ref) self._apply_update({}, {'js_events': js_events}, model, ref) diff --git a/panel/pane/holoviews.py b/panel/pane/holoviews.py index cc3ea591f2..f157072775 100644 --- a/panel/pane/holoviews.py +++ b/panel/pane/holoviews.py @@ -385,6 +385,9 @@ def _sync_sizing_mode(self, plot): 'width': None, 'height': None } + else: + params = {} + self._syncing_props = True try: self.param.update({k: v for k, v in params.items() if k not in self._overrides}) diff --git a/panel/pane/markup.py b/panel/pane/markup.py index c98cd5d693..583903a932 100644 --- a/panel/pane/markup.py +++ b/panel/pane/markup.py @@ -15,8 +15,8 @@ import param # type: ignore from ..io.resources import CDN_DIST -from ..models import HTML as _BkHTML, JSON as _BkJSON -from ..util import HTML_SANITIZER, escape +from ..models.markup import HTML as _BkHTML, JSON as _BkJSON, HTMLStreamEvent +from ..util import HTML_SANITIZER, escape, prefix_length from .base import ModelPane if TYPE_CHECKING: @@ -24,6 +24,7 @@ from bokeh.model import Model from pyviz_comms import Comm # type: ignore + class HTMLBasePane(ModelPane): """ Baseclass for Panes which render HTML inside a Bokeh Div. @@ -31,14 +32,30 @@ class HTMLBasePane(ModelPane): the supported options like style and sizing_mode. """ + enable_streaming = param.Boolean(default=False, doc=""" + Whether to enable streaming of text snippets. This is useful + when updating a string step by step, e.g. in a chat message.""") + _bokeh_model: ClassVar[Model] = _BkHTML - _rename: ClassVar[Mapping[str, str | None]] = {'object': 'text'} + _rename: ClassVar[Mapping[str, str | None]] = {'object': 'text', 'enable_streaming': None} _updates: ClassVar[bool] = True __abstract = True + def _update(self, ref: str, model: Model) -> None: + props = self._get_properties(model.document) + if self.enable_streaming and 'text' in props: + text = props['text'] + start = prefix_length(text, model.text) + model.run_scripts = False + patch = text[start:] + self._send_event(HTMLStreamEvent, patch=patch, start=start) + model._property_values['text'] = model.text[:start]+patch + del props['text'] + model.update(**props) + class HTML(HTMLBasePane): """ @@ -71,7 +88,7 @@ class HTML(HTMLBasePane): priority: ClassVar[float | bool | None] = None _rename: ClassVar[Mapping[str, str | None]] = { - 'sanitize_html': None, 'sanitize_hook': None + 'sanitize_html': None, 'sanitize_hook': None, 'stream': None } _rerender_params: ClassVar[list[str]] = [ @@ -176,6 +193,10 @@ class DataFrame(HTML): Set to False for a DataFrame with a hierarchical index to print every multi-index key at each row.""") + text_align = param.Selector(default=None, objects=[ + 'start', 'end', 'center'], doc=""" + Alignment of non-header cells.""") + _object = param.Parameter(default=None, doc="""Hidden parameter.""") _dask_params: ClassVar[list[str]] = ['max_rows'] @@ -185,7 +206,7 @@ class DataFrame(HTML): 'col_space', 'decimal', 'escape', 'float_format', 'formatters', 'header', 'index', 'index_names', 'justify', 'max_rows', 'max_cols', 'na_rep', 'render_links', 'show_dimensions', - 'sparsify', 'sizing_mode' + 'sparsify', 'text_align', 'sizing_mode' ] _rename: ClassVar[Mapping[str, str | None]] = { @@ -244,14 +265,18 @@ def _transform_object(self, obj: Any) -> dict[str, Any]: module = getattr(obj, '__module__', '') if hasattr(obj, 'to_html'): + classes = list(self.classes) + if self.text_align: + classes.append(f'{self.text_align}-align') if 'dask' in module: html = obj.to_html(max_rows=self.max_rows).replace('border="1"', '') elif 'style' in module: - classes = ' '.join(self.classes) + classes = ' '.join(classes) html = obj.to_html(table_attributes=f'class="{classes}"') else: kwargs = {p: getattr(self, p) for p in self._rerender_params - if p not in HTMLBasePane.param and p != '_object'} + if p not in HTMLBasePane.param and p not in ('_object', 'text_align')} + kwargs['classes'] = classes html = obj.to_html(**kwargs) else: html = '' diff --git a/panel/pane/plotly.py b/panel/pane/plotly.py index f7796667f0..a8a20f943b 100644 --- a/panel/pane/plotly.py +++ b/panel/pane/plotly.py @@ -206,7 +206,7 @@ def _send_update_msg( msg['relayout'] = relayout_data if restyle_data: msg['restyle'] = {'data': restyle_data, 'traces': trace_indexes} - for ref, (m, _) in self._models.items(): + for ref, (m, _) in self._models.copy().items(): self._apply_update([], msg, m, ref) def _update_from_figure(self, event, *args, **kwargs): diff --git a/panel/pane/vega.py b/panel/pane/vega.py index 854536eb43..081ec5661f 100644 --- a/panel/pane/vega.py +++ b/panel/pane/vega.py @@ -267,7 +267,7 @@ def _get_properties(self, doc, sources={}): data = props['data'] if data is not None: sources = self._get_sources(data, sources) - if self.sizing_mode: + if self.sizing_mode and data: if 'both' in self.sizing_mode: if 'width' in data: data['width'] = 'container' diff --git a/panel/pane/vtk/vtk.py b/panel/pane/vtk/vtk.py index 1df5ed8081..99b22ec452 100644 --- a/panel/pane/vtk/vtk.py +++ b/panel/pane/vtk/vtk.py @@ -782,7 +782,10 @@ def _subsample_array(self, array): if any([d_f > 1 for d_f in dowsnscale_factor]): try: import scipy.ndimage as nd - sub_array = nd.interpolation.zoom(array, zoom=[1 / d_f for d_f in dowsnscale_factor], order=0, mode="nearest") + if hasattr(nd, "zoom"): + sub_array = nd.zoom(array, zoom=[1 / d_f for d_f in dowsnscale_factor], order=0, mode="nearest") + else: # Slated for removal in 2.0 + sub_array = nd.interpolation.zoom(array, zoom=[1 / d_f for d_f in dowsnscale_factor], order=0, mode="nearest") except ImportError: sub_array = array[::int(np.ceil(dowsnscale_factor[0])), ::int(np.ceil(dowsnscale_factor[1])), diff --git a/panel/reactive.py b/panel/reactive.py index e61c506cff..0ce19307f2 100644 --- a/panel/reactive.py +++ b/panel/reactive.py @@ -138,6 +138,9 @@ def __init__(self, **params): # A dictionary of bokeh property changes being processed self._changing = {} + # A dictionary of parameter changes being processed + self._in_process__events = {} + # Whether the component is watching the stylesheets self._watching_stylesheets = False @@ -288,7 +291,7 @@ def _manual_update( """ def _update_manual(self, *events: param.parameterized.Event) -> None: - for ref, (model, parent) in self._models.items(): + for ref, (model, parent) in self._models.copy().items(): if ref not in state._views or ref in state._fake_roots: continue viewable, root, doc, comm = state._views[ref] @@ -304,21 +307,36 @@ def _update_manual(self, *events: param.parameterized.Event) -> None: else: cb() + def _scheduled_update_model( + self, events: dict[str, param.parameterized.Event], msg: dict[str, Any], + root: Model, model: Model, doc: Document, comm: Optional[Comm], + curdoc_events: dict[str, Any] + ) -> None: + # + self._in_process__events[doc] = curdoc_events + try: + self._update_model(events, msg, root, model, doc, comm) + finally: + del self._in_process__events[doc] + def _apply_update( self, events: dict[str, param.parameterized.Event], msg: dict[str, Any], model: Model, ref: str - ) -> None: + ) -> bool: if ref not in state._views or ref in state._fake_roots: - return + return True viewable, root, doc, comm = state._views[ref] if comm or not doc.session_context or state._unblocked(doc): with unlocked(): self._update_model(events, msg, root, model, doc, comm) if comm and 'embedded' not in root.tags: push(doc, comm) + return True else: - cb = partial(self._update_model, events, msg, root, model, doc, comm) + curdoc_events = self._in_process__events.pop(doc, {}) + cb = partial(self._scheduled_update_model, events, msg, root, model, doc, comm, curdoc_events) doc.add_next_tick_callback(cb) + return False def _update_model( self, events: dict[str, param.parameterized.Event], msg: dict[str, Any], @@ -326,7 +344,20 @@ def _update_model( ) -> None: ref = root.ref['id'] self._changing[ref] = attrs = [] - for attr, value in msg.items(): + curdoc_events = self._in_process__events.get(doc, {}) + for attr, value in msg.copy().items(): + if attr in curdoc_events and value is curdoc_events[attr]: + # Do not apply change that originated directly from + # the frontend since this may cause boomerang if a + # new property value is already in-flight + del msg[attr] + continue + elif attr in self._events: + # Do not override a property value that was just changed + # on the frontend + del self._events[attr] + continue + # Bokeh raises UnsetValueError if the value is Undefined. try: model_val = getattr(model, attr) @@ -336,10 +367,6 @@ def _update_model( if not model.lookup(attr).property.matches(model_val, value): attrs.append(attr) - # Do not apply model change that is in flight - if attr in self._events: - del self._events[attr] - try: model.update(**msg) finally: @@ -405,18 +432,26 @@ async def _watch_stylesheets(self): def _param_change(self, *events: param.parameterized.Event) -> None: named_events = {event.name: event for event in events} + applied = False for ref, (model, _) in self._models.copy().items(): properties = self._update_properties(*events, doc=model.document) if not properties: return - self._apply_update(named_events, properties, model, ref) + applied &= self._apply_update(named_events, properties, model, ref) + if ref not in state._views: + continue + doc = state._views[ref][2] + if applied and doc in self._in_process__events: + del self._in_process__events[doc] def _process_events(self, events: dict[str, Any]) -> None: self._log('received events %s', events) if any(e for e in events if e not in self._busy__ignore): with edit_readonly(state): state._busy_counter += 1 - params = self._process_property_change(dict(events)) + if events: + self._in_process__events[state.curdoc] = events + params = self._process_property_change(events) try: with edit_readonly(self): self_params = {k: v for k, v in params.items() if '.' not in k} @@ -892,7 +927,7 @@ def _send_event(self, Event: ModelEvent, **event_kwargs): Event(model=model, **event_kwargs) """ - for ref, (model, _) in self._models.items(): + for ref, (model, _) in self._models.copy().items(): if ref not in state._views or ref in state._fake_roots: continue event = Event(model=model, **event_kwargs) @@ -994,7 +1029,7 @@ def _update_cds(self, *events: param.parameterized.Event) -> None: self._processed, self._data = self._get_data() msg = {'data': self._data} named_events = {event.name: event for event in events} - for ref, (m, _) in self._models.items(): + for ref, (m, _) in self._models.copy().items(): self._apply_update(named_events, msg, m.source, ref) @updating @@ -1004,7 +1039,7 @@ def _update_selected( indices = self.selection if indices is None else indices msg = {'indices': indices} named_events = {event.name: event for event in events} - for ref, (m, _) in self._models.items(): + for ref, (m, _) in self._models.copy().items(): self._apply_update(named_events, msg, m.source.selected, ref) def _apply_stream(self, ref: str, model: Model, stream: 'DataDict', rollover: Optional[int]) -> None: @@ -1017,7 +1052,7 @@ def _apply_stream(self, ref: str, model: Model, stream: 'DataDict', rollover: Op @updating def _stream(self, stream: 'DataDict', rollover: Optional[int] = None) -> None: self._processed, _ = self._get_data() - for ref, (m, _) in self._models.items(): + for ref, (m, _) in self._models.copy().items(): if ref not in state._views or ref in state._fake_roots: continue viewable, root, doc, comm = state._views[ref] @@ -1039,7 +1074,7 @@ def _apply_patch(self, ref: str, model: Model, patch: 'Patches') -> None: @updating def _patch(self, patch: 'Patches') -> None: - for ref, (m, _) in self._models.items(): + for ref, (m, _) in self._models.copy().items(): if ref not in state._views or ref in state._fake_roots: continue viewable, root, doc, comm = state._views[ref] @@ -1275,9 +1310,6 @@ class ReactiveData(SyncableData): __abstract = True - def __init__(self, **params): - super().__init__(**params) - def _update_selection(self, indices: list[int]) -> None: self.selection = indices @@ -1289,21 +1321,14 @@ def _convert_column( if dtype.kind == 'M': if values.dtype.kind in 'if': if getattr(dtype, 'tz', None): - # dtype has a timezone - if dtype.tz == dt.timezone.utc: - # Milliseconds to nanoseconds, to datetime64. - converted = (values * 1e6).astype('datetime64[ns]') - else: - import pandas as pd - - # Using pandas to convert from milliseconds - # timezone-aware, to UTC nanoseconds, to datetime64. - converted = ( - pd.Series(pd.to_datetime(values, unit="ms")) - .dt.tz_localize(dtype.tz) - .dt.tz_convert('utc') - .dt.tz_localize(None) - ) + import pandas as pd + + # Using pandas to convert from milliseconds + # timezone-aware, to UTC nanoseconds, to datetime64. + converted = ( + pd.Series(pd.to_datetime(values, unit="ms")) + .dt.tz_localize(dtype.tz) + ) else: # Timestamps converted from milliseconds to nanoseconds, # to datetime. @@ -2151,7 +2176,7 @@ def on_event(self, node: str, event: str, callback: Callable) -> None: f"nodes include: {self._parser.nodes}.") self._event_callbacks[node][event].append(callback) events = self._get_events() - for ref, (model, _) in self._models.items(): + for ref, (model, _) in self._models.copy().items(): self._apply_update({}, {'events': events}, model, ref) __all__ = ( diff --git a/panel/tests/chat/test_feed.py b/panel/tests/chat/test_feed.py index 619c9fbafd..64aa7304d4 100644 --- a/panel/tests/chat/test_feed.py +++ b/panel/tests/chat/test_feed.py @@ -40,6 +40,12 @@ def test_init_with_help_text(self): assert message.object == "Instructions" assert message.user == "Help" + def test_init_with_loading(self): + chat_feed = ChatFeed(loading=True) + assert chat_feed._placeholder in chat_feed._chat_log + chat_feed.loading = False + assert chat_feed._placeholder not in chat_feed._chat_log + def test_update_header(self): chat_feed = ChatFeed(header="1") assert chat_feed._card.header == "1" @@ -200,6 +206,7 @@ def test_add_step(self, chat_feed): assert len(chat_feed) == 1 message = chat_feed.objects[0] assert isinstance(message, ChatMessage) + assert message.user == "Assistant" steps = message.object assert isinstance(steps, Column) @@ -272,7 +279,7 @@ def test_add_step_explict_not_append(self, chat_feed): assert len(chat_feed) == 2 message1 = chat_feed.objects[0] assert isinstance(message1, ChatMessage) - assert message1.user == "User" + assert message1.user == "Assistant" steps1 = message1.object assert isinstance(steps1, Column) assert len(steps1) == 1 @@ -282,7 +289,7 @@ def test_add_step_explict_not_append(self, chat_feed): message2 = chat_feed.objects[1] assert isinstance(message2, ChatMessage) - assert message2.user == "User" + assert message2.user == "Assistant" steps2 = message2.object assert isinstance(steps2, Column) assert len(steps2) == 1 @@ -601,6 +608,140 @@ def test_update_chat_log_params(self, chat_feed): assert chat_feed._chat_log.scroll_button_threshold == 10 assert chat_feed._chat_log.auto_scroll_limit == 10 + +@pytest.mark.xdist_group("chat") +class TestChatFeedPromptUser: + + async def test_prompt_user_basic(self, chat_feed): + text_input = TextInput() + + def callback(component, feed): + feed.send(component.value) + + async def prompt_and_submit(): + chat_feed.prompt_user(text_input, callback) + await async_wait_until(lambda: len(chat_feed.objects) == 1) + text_input.value = "test input" + submit_button = chat_feed.objects[-1].object[1] + submit_button.clicks += 1 + await async_wait_until(lambda: len(chat_feed.objects) == 2) + + await asyncio.wait_for(prompt_and_submit(), timeout=5.0) + assert chat_feed.objects[-1].object == "test input" + + async def test_prompt_user_with_predicate(self, chat_feed): + text_input = TextInput() + + def predicate(component): + return len(component.value) > 5 + + def callback(component, feed): + feed.send(component.value) + + async def prompt_and_submit(): + chat_feed.prompt_user(text_input, callback, predicate=predicate) + await async_wait_until(lambda: len(chat_feed.objects) == 1) + + text_input.value = "short" + submit_button = chat_feed.objects[-1].object[1] + assert submit_button.disabled + + text_input.value = "long enough" + await async_wait_until(lambda: not submit_button.disabled) + + submit_button.clicks += 1 + await async_wait_until(lambda: len(chat_feed.objects) == 2) + + await asyncio.wait_for(prompt_and_submit(), timeout=5.0) + assert chat_feed.objects[-1].object == "long enough" + + async def test_prompt_user_timeout(self, chat_feed): + text_input = TextInput() + + def callback(component, feed): + pytest.fail("Callback should not be called on timeout") + + async def prompt_and_wait(): + chat_feed.prompt_user(text_input, callback, timeout=1) + await async_wait_until(lambda: len(chat_feed.objects) == 1) + await async_wait_until(lambda: chat_feed.objects[-1].object[1].disabled) + + await asyncio.wait_for(prompt_and_wait(), timeout=5.0) + + submit_button = chat_feed.objects[-1].object[1] + assert submit_button.name == "Timed out" + assert submit_button.button_type == "light" + assert submit_button.icon == "x" + + async def test_prompt_user_custom_button_params(self, chat_feed): + text_input = TextInput() + + def callback(component, feed): + feed.send(component.value) + + custom_button_params = { + "name": "Custom Submit", + "button_type": "success", + "icon": "arrow-right" + } + + async def prompt_and_check(): + chat_feed.prompt_user(text_input, callback, button_params=custom_button_params) + await async_wait_until(lambda: len(chat_feed.objects) == 1) + + await asyncio.wait_for(prompt_and_check(), timeout=5.0) + + submit_button = chat_feed.objects[-1].object[1] + assert submit_button.name == "Custom Submit" + assert submit_button.button_type == "success" + assert submit_button.icon == "arrow-right" + + async def test_prompt_user_custom_timeout_button_params(self, chat_feed): + text_input = TextInput() + + def callback(component, feed): + pytest.fail("Callback should not be called on timeout") + + custom_timeout_params = { + "name": "Custom Timeout", + "button_type": "danger", + "icon": "alert-triangle" + } + + async def prompt_and_wait(): + chat_feed.prompt_user(text_input, callback, timeout=1, timeout_button_params=custom_timeout_params) + await async_wait_until(lambda: len(chat_feed.objects) == 1) + await async_wait_until(lambda: chat_feed.objects[-1].object[1].disabled) + + await asyncio.wait_for(prompt_and_wait(), timeout=5.0) + + submit_button = chat_feed.objects[-1].object[1] + assert submit_button.name == "Custom Timeout" + assert submit_button.button_type == "danger" + assert submit_button.icon == "alert-triangle" + + async def test_prompt_user_async(self, chat_feed): + text_input = TextInput() + + async def async_callback(component, feed): + await asyncio.sleep(0.1) + feed.send("Callback executed") + + async def prompt_and_submit(): + chat_feed.prompt_user(text_input, async_callback) + await async_wait_until(lambda: len(chat_feed.objects) == 1) + + submit_button = chat_feed.objects[-1].object[1] + submit_button.clicks += 1 + + await async_wait_until(lambda: len(chat_feed.objects) == 2) + + await asyncio.wait_for(prompt_and_submit(), timeout=5.0) + + assert chat_feed.objects[-1].object == "Callback executed" + assert chat_feed.objects[-2].object.disabled == True + + @pytest.mark.xdist_group("chat") class TestChatFeedCallback: @@ -1136,6 +1277,17 @@ def callback(cls, contents, user): wait_until(lambda: len(chat_feed.objects) == 2) assert chat_feed.objects[1].object == "User: Message" + def test_persist_placeholder_while_loading(self, chat_feed): + def callback(contents): + assert chat_feed._placeholder in chat_feed._chat_log + return "hey testing" + + chat_feed.loading = True + chat_feed.callback = callback + chat_feed.send("Message", respond=True) + assert chat_feed._placeholder in chat_feed._chat_log + + @pytest.mark.xdist_group("chat") class TestChatFeedSerializeForTransformers: @@ -1296,6 +1448,17 @@ def __repr__(self): chat_feed.send(Test()) assert chat_feed.serialize() == [{"role": "user", "content": "Test()"}] + def test_serialize_kwargs(self, chat_feed): + chat_feed.send("Hello") + chat_feed.add_step("Hello", "World") + assert chat_feed.serialize( + prefix_with_container_label=False, + prefix_with_viewable_label=False + ) == [ + {'role': 'user', 'content': 'Hello'}, + {'role': 'assistant', 'content': '((Hello))'} + ] + @pytest.mark.xdist_group("chat") class TestChatFeedSerializeBase: diff --git a/panel/tests/chat/test_icon.py b/panel/tests/chat/test_icon.py index 8c1f5d5217..0218b2a84c 100644 --- a/panel/tests/chat/test_icon.py +++ b/panel/tests/chat/test_icon.py @@ -19,7 +19,8 @@ def test_options(self): assert "dislike" in icons._rendered_icons assert icons._rendered_icons["dislike"].icon == "thumb-down" assert icons._rendered_icons["dislike"].active_icon == "" - assert len(icons._composite) == 2 + assert len(icons._composite) == 1 + assert len(icons._composite[0]) == 2 icons.options = {"favorite": "heart"} assert icons.options == {"favorite": "heart"} @@ -27,6 +28,7 @@ def test_options(self): assert icons._rendered_icons["favorite"].icon == "heart" assert icons._rendered_icons["favorite"].active_icon == "" assert len(icons._composite) == 1 + assert len(icons._composite[0]) == 1 def test_value(self): icons = ChatReactionIcons( diff --git a/panel/tests/chat/test_message.py b/panel/tests/chat/test_message.py index 1a0bbe41b0..e3b694e56c 100644 --- a/panel/tests/chat/test_message.py +++ b/panel/tests/chat/test_message.py @@ -36,27 +36,28 @@ def test_layout(self): assert isinstance(avatar_pane, HTML) assert avatar_pane.object == "🧑" - header_row = columns[1][0] - user_pane = header_row[0] + meta_row = columns[1][0] + user_pane = meta_row[0] assert isinstance(user_pane, HTML) assert user_pane.object == "User" + header_row = columns[1][1] + assert isinstance(header_row[0], Markdown) + assert header_row[0].object == "Header Test" 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" + assert header_row[1].object == "Header 2" - center_row = columns[1][1] + center_row = columns[1][2] assert isinstance(center_row, Row) object_pane = center_row[0] assert isinstance(object_pane, Markdown) assert object_pane.object == "ABC" - icons = center_row[1] + icons = columns[1][5][2] assert isinstance(icons, ChatReactionIcons) - footer_col = columns[1][2] + footer_col = columns[1][3] assert isinstance(footer_col, Column) assert isinstance(footer_col[0], Markdown) @@ -64,7 +65,7 @@ def test_layout(self): assert isinstance(footer_col[1], Markdown) assert footer_col[1].object == "Footer 2" - timestamp_pane = footer_col[2] + timestamp_pane = columns[1][4][0] assert isinstance(timestamp_pane, HTML) def test_reactions_dynamic(self): @@ -79,7 +80,7 @@ def test_reaction_icons_dynamic(self): assert message.reaction_icons.options == {"favorite": "heart"} message.reaction_icons = ChatReactionIcons(options={"like": "thumb-up"}) - assert message._center_row[1] == message.reaction_icons + assert message._icons_row[-1] == message.reaction_icons def test_reactions_link(self): # on init @@ -150,19 +151,19 @@ def test_update_user(self): def test_update_object(self): message = ChatMessage(object="Test") columns = message._composite.objects - object_pane = columns[1][1][0] + object_pane = columns[1][2][0] assert isinstance(object_pane, Markdown) assert object_pane.object == "Test" message.object = TextInput(value="Also testing...") - object_pane = columns[1][1][0] + object_pane = columns[1][2][0] assert isinstance(object_pane, TextInput) assert object_pane.value == "Also testing..." message.object = _FileInputMessage( contents=b"I am a file", file_name="test.txt", mime_type="text/plain" ) - object_pane = columns[1][1][0] + object_pane = columns[1][2][0] assert isinstance(object_pane, Markdown) assert object_pane.object == "I am a file" @@ -170,39 +171,39 @@ def test_update_object(self): def test_update_timestamp(self): message = ChatMessage() columns = message._composite.objects - timestamp_pane = columns[1][2][0] + timestamp_pane = columns[1][4][0] assert isinstance(timestamp_pane, HTML) dt_str = datetime.datetime.now().strftime("%H:%M") assert timestamp_pane.object == dt_str message = ChatMessage(timestamp_tz="UTC") columns = message._composite.objects - timestamp_pane = columns[1][2][0] + timestamp_pane = columns[1][4][0] assert isinstance(timestamp_pane, HTML) - dt_str = datetime.datetime.utcnow().strftime("%H:%M") + dt_str = datetime.datetime.now(datetime.timezone.utc).strftime("%H:%M") assert timestamp_pane.object == dt_str message = ChatMessage(timestamp_tz="US/Pacific") columns = message._composite.objects - timestamp_pane = columns[1][2][0] + timestamp_pane = columns[1][4][0] assert isinstance(timestamp_pane, HTML) dt_str = datetime.datetime.now(tz=ZoneInfo("US/Pacific")).strftime("%H:%M") assert timestamp_pane.object == dt_str special_dt = datetime.datetime(2023, 6, 24, 15) message.timestamp = special_dt - timestamp_pane = columns[1][2][0] + timestamp_pane = columns[1][4][0] dt_str = special_dt.strftime("%H:%M") assert timestamp_pane.object == dt_str mm_dd_yyyy = "%b %d, %Y" message.timestamp_format = mm_dd_yyyy - timestamp_pane = columns[1][2][0] + timestamp_pane = columns[1][4][0] dt_str = special_dt.strftime(mm_dd_yyyy) assert timestamp_pane.object == dt_str message.show_timestamp = False - timestamp_pane = columns[1][2][0] + timestamp_pane = columns[1][4][0] assert not timestamp_pane.visible def test_does_not_turn_widget_into_str(self): diff --git a/panel/tests/chat/test_step.py b/panel/tests/chat/test_step.py index 43e529905c..017353d85d 100644 --- a/panel/tests/chat/test_step.py +++ b/panel/tests/chat/test_step.py @@ -127,3 +127,13 @@ def test_stream_none(self): step.stream("abc") assert len(step) == 1 assert step[0].object == "abc" + + def test_header_inherits_width(self): + step = ChatStep(width=100) + assert step.header.width == 100 + + @pytest.mark.parametrize("width_key", ["max_width", "min_width"]) + def test_header_inherits_stretch_width(self, width_key): + step = ChatStep(**{width_key: 100}, sizing_mode="stretch_width") + assert getattr(step.header, width_key) == 100 + assert step.header.sizing_mode == "stretch_width" diff --git a/panel/tests/conftest.py b/panel/tests/conftest.py index 8126d69d70..08dee3e78a 100644 --- a/panel/tests/conftest.py +++ b/panel/tests/conftest.py @@ -3,6 +3,7 @@ """ import asyncio import atexit +import datetime as dt import os import pathlib import re @@ -12,7 +13,6 @@ import tempfile import time import unittest -import warnings from contextlib import contextmanager from subprocess import PIPE, Popen @@ -43,9 +43,7 @@ JUPYTER_PROCESS = None try: - with warnings.catch_warnings(): - warnings.filterwarnings("error", category=DeprecationWarning) - asyncio.get_event_loop() + asyncio.get_event_loop() except (RuntimeError, DeprecationWarning): asyncio.set_event_loop(asyncio.new_event_loop()) @@ -496,3 +494,37 @@ def eh(exception): yield exceptions finally: config.exception_handler = old_eh + + +@pytest.fixture +def df_mixed(): + df = pd.DataFrame({ + 'int': [1, 2, 3, 4], + 'float': [3.14, 6.28, 9.42, -2.45], + 'str': ['A', 'B', 'C', 'D'], + 'bool': [True, True, True, False], + 'date': [dt.date(2019, 1, 1), dt.date(2020, 1, 1), dt.date(2020, 1, 10), dt.date(2019, 1, 10)], + 'datetime': [dt.datetime(2019, 1, 1, 10), dt.datetime(2020, 1, 1, 12), dt.datetime(2020, 1, 10, 13), dt.datetime(2020, 1, 15, 13)] + }, index=['idx0', 'idx1', 'idx2', 'idx3']) + return df + +@pytest.fixture +def df_strings(): + descr = [ + 'Under the Weather', + 'Top Drawer', + 'Happy as a Clam', + 'Cut To The Chase', + 'Knock Your Socks Off', + 'A Cold Day in Hell', + 'All Greek To Me', + 'A Cut Above', + 'Cut The Mustard', + 'Up In Arms', + 'Playing For Keeps', + 'Fit as a Fiddle', + ] + + code = [f'{i:02d}' for i in range(len(descr))] + + return pd.DataFrame(dict(code=code, descr=descr)) diff --git a/panel/tests/io/test_reload.py b/panel/tests/io/test_reload.py index b9638e92cd..902b72d894 100644 --- a/panel/tests/io/test_reload.py +++ b/panel/tests/io/test_reload.py @@ -12,9 +12,10 @@ def test_record_modules_not_stdlib(): + old_modules = _modules.copy() with record_modules(): - import audioop # noqa - assert ((_modules == set()) or (_modules == set(['audioop']))) + import dis # noqa + assert _modules == old_modules _modules.clear() def test_check_file(): diff --git a/panel/tests/pane/test_vega.py b/panel/tests/pane/test_vega.py index 99f29620e4..436befab4c 100644 --- a/panel/tests/pane/test_vega.py +++ b/panel/tests/pane/test_vega.py @@ -322,3 +322,7 @@ def test_altair_pane(document, comm): pane._cleanup(model) assert pane._models == {} + +def test_vega_can_instantiate_empty_with_sizing_mode(document, comm): + pane = Vega(sizing_mode="stretch_width") + pane.get_root(document, comm=comm) diff --git a/panel/tests/pane/test_vtk.py b/panel/tests/pane/test_vtk.py index 188f22123b..13c5b6a8b2 100644 --- a/panel/tests/pane/test_vtk.py +++ b/panel/tests/pane/test_vtk.py @@ -407,7 +407,7 @@ def test_vtkvol_serialization_coherence(document, comm): vd_f = p_f._get_volume_data() vd_id = p_id._get_volume_data() data_decoded = np.frombuffer(base64.b64decode(vd_c["buffer"]), dtype=vd_c["dtype"]).reshape(vd_c["dims"], order="F") - assert (data_decoded==data_matrix).all() + assert np.all(data_decoded==data_matrix) assert vd_id == vd_c == vd_f p_c_ds = VTKVolume(data_matrix_c, origin=origin, spacing=spacing, max_data_size=0.1) diff --git a/panel/tests/test_models.py b/panel/tests/test_models.py index 252345fc14..c6af59eea4 100644 --- a/panel/tests/test_models.py +++ b/panel/tests/test_models.py @@ -7,4 +7,5 @@ def test_models_encoding(): model_dir = os.path.join(panel.__path__[0], 'models') for file in os.listdir(model_dir): if file.endswith('.ts'): - open(os.path.join(model_dir, file), 'r').read() + with open(os.path.join(model_dir, file), 'r') as f: + f.read() diff --git a/panel/tests/test_param.py b/panel/tests/test_param.py index e3801ec98d..7ea286e55d 100644 --- a/panel/tests/test_param.py +++ b/panel/tests/test_param.py @@ -574,7 +574,7 @@ class Test(param.Parameterized): assert mb.value != 3 assert test.b == 3 - test_pane._widgets['b']._process_events({'value': 4}) + mb.value = 4 assert test.b == 3 assert mb.value == 4 @@ -616,7 +616,7 @@ class Test(param.Parameterized): assert mb.value != '3' assert test.b == '3' - test_pane._widgets['b']._process_events({'value': '4'}) + mb.value = '4' assert test.b == '3' assert mb.value == '4' @@ -873,7 +873,7 @@ class Test(param.Parameterized): assert number.value != 3 assert test.a == 3 - pane._widgets['a']._process_events({'value': 4}) + number.value = 4 assert test.a == 3 assert number.value == 4 diff --git a/panel/tests/test_reactive.py b/panel/tests/test_reactive.py index 232a86e491..9d1bc6f4f9 100644 --- a/panel/tests/test_reactive.py +++ b/panel/tests/test_reactive.py @@ -15,6 +15,7 @@ from bokeh.models import Div from panel.depends import bind, depends +from panel.io.state import set_curdoc from panel.layout import Tabs, WidgetBox from panel.pane import Markdown from panel.reactive import Reactive, ReactiveHTML @@ -370,6 +371,19 @@ def test_text_input_controls_explicit(): text_input.placeholder = "Test placeholder..." assert placeholder.value == "Test placeholder..." +def test_property_change_does_not_boomerang(document, comm): + text_input = TextInput(value='A') + + model = text_input.get_root(document, comm) + + assert model.value == 'A' + model.value = 'B' + with set_curdoc(document): + text_input._process_events({'value': 'C'}) + + assert model.value == 'B' + assert text_input.value == 'C' + def test_reactive_html_basic(): class Test(ReactiveHTML): diff --git a/panel/tests/test_server.py b/panel/tests/test_server.py index c65629ff20..4099109786 100644 --- a/panel/tests/test_server.py +++ b/panel/tests/test_server.py @@ -382,8 +382,8 @@ def periodic_cb(): state.cache['at'].append(dt.datetime.now()) scheduled = [ - dt.datetime.utcnow() + dt.timedelta(seconds=1.57), - dt.datetime.utcnow() + dt.timedelta(seconds=1.86) + dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=1.57), + dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=1.86) ] siter = iter(scheduled) diff --git a/panel/tests/ui/chat/test_chat_interface_ui.py b/panel/tests/ui/chat/test_chat_interface_ui.py index 6f26698c97..4f764bd7bd 100644 --- a/panel/tests/ui/chat/test_chat_interface_ui.py +++ b/panel/tests/ui/chat/test_chat_interface_ui.py @@ -2,8 +2,10 @@ pytest.importorskip("playwright") +from playwright.sync_api import expect + from panel.chat import ChatInterface -from panel.tests.util import serve_component, wait_until +from panel.tests.util import serve_component pytestmark = pytest.mark.ui @@ -13,9 +15,8 @@ def test_chat_interface_help(page): help_text="This is a test help text" ) serve_component(page, chat_interface) - message = page.locator("p") - message_text = message.inner_text() - wait_until(lambda: message_text == "This is a test help text", page) + + expect(page.locator("p")).to_have_text("This is a test help text") def test_chat_interface_custom_js(page): @@ -38,7 +39,7 @@ def test_chat_interface_custom_js(page): page.locator("button", has_text="help").click() msg = msg_info.value - wait_until(lambda: msg.args[0].json_value() == "Typed: 'Hello'", page) + assert msg.args[0].json_value() == "Typed: 'Hello'" def test_chat_interface_custom_js_string(page): @@ -58,4 +59,4 @@ def test_chat_interface_custom_js_string(page): page.locator("button", has_text="help").click() msg = msg_info.value - wait_until(lambda: msg.args[0].json_value() == "Clicked", page) + assert msg.args[0].json_value() == "Clicked" diff --git a/panel/tests/ui/io/test_convert.py b/panel/tests/ui/io/test_convert.py index 09546b5d60..41df409ed5 100644 --- a/panel/tests/ui/io/test_convert.py +++ b/panel/tests/ui/io/test_convert.py @@ -118,7 +118,8 @@ def http_serve(): temp_dir = tempfile.TemporaryDirectory() temp_path = pathlib.Path(temp_dir.name) - (temp_path / 'test.html').write_text('Test') + test_file = (temp_path / 'test.html') + test_file.write_text('Test') try: shutil.copy(PANEL_LOCAL_WHL, temp_path / PANEL_LOCAL_WHL.name) @@ -131,7 +132,6 @@ def http_serve(): httpd, _ = http_serve_directory(str(temp_path), port=HTTP_PORT) - time.sleep(1) def write(app): @@ -141,9 +141,11 @@ def write(app): f.write(app) return app_path - yield write - - httpd.shutdown() + try: + yield write + finally: + httpd.shutdown() + temp_dir.cleanup() def wait_for_app(http_serve, app, page, runtime, wait=True, **kwargs): diff --git a/panel/tests/ui/io/test_jupyterlite.py b/panel/tests/ui/io/test_jupyterlite.py index 4ccd789a8e..656a3bbb71 100644 --- a/panel/tests/ui/io/test_jupyterlite.py +++ b/panel/tests/ui/io/test_jupyterlite.py @@ -1,3 +1,4 @@ +import sys import time from http.client import HTTPConnection @@ -15,7 +16,7 @@ @pytest.fixture() def launch_jupyterlite(): process = Popen( - ["python", "-m", "http.server", "8123", "--directory", 'lite/dist/'], stdout=PIPE + [sys.executable, "-m", "http.server", "8123", "--directory", 'lite/dist/'], stdout=PIPE ) retries = 5 while retries > 0: @@ -24,12 +25,15 @@ def launch_jupyterlite(): conn.request("HEAD", 'index.html') response = conn.getresponse() if response is not None: + conn.close() break except ConnectionRefusedError: time.sleep(1) retries -= 1 if not retries: + process.terminate() + process.wait() raise RuntimeError("Failed to start http server") try: yield @@ -38,7 +42,7 @@ def launch_jupyterlite(): process.wait() - +@pytest.mark.filterwarnings("ignore::ResourceWarning") def test_jupyterlite_execution(launch_jupyterlite, page): # INFO: Needs TS changes uploaded to CDN. Relevant when # testing a new version of Bokeh. @@ -52,7 +56,7 @@ def test_jupyterlite_execution(launch_jupyterlite, page): page.locator('.jp-Dialog-footer > button').nth(1).click() for _ in range(6): - page.locator('button[data-command="notebook:run-cell-and-select-next"]').click() + page.locator('jp-button[data-command="notebook:run-cell-and-select-next"]').click() page.wait_for_timeout(500) page.locator('.noUi-handle').click(timeout=120 * 1000) diff --git a/panel/tests/ui/io/test_reload.py b/panel/tests/ui/io/test_reload.py index 4725e05a04..cc5b582375 100644 --- a/panel/tests/ui/io/test_reload.py +++ b/panel/tests/ui/io/test_reload.py @@ -53,6 +53,33 @@ def test_reload_app_with_error(page, autoreload, py_file): expect(page.locator('.alert')).to_have_count(1) +def test_reload_app_with_syntax_error(page, autoreload, py_file): + py_file.write("import panel as pn; pn.panel('foo').servable();") + py_file.close() + + path = pathlib.Path(py_file.name) + + autoreload(path) + serve_component(page, path) + + expect(page.locator('.markdown')).to_have_text('foo') + + with open(py_file.name, 'w') as f: + f.write("foo?bar") + os.fsync(f) + + expect(page.locator('.alert')).to_have_count(1) + +def test_load_app_with_no_content(page, autoreload, py_file): + py_file.write("import panel as pn; pn.panel('foo')") + py_file.close() + + path = pathlib.Path(py_file.name) + + serve_component(page, path) + + expect(page.locator('.alert')).to_have_count(1) + @pytest.mark.flaky(reruns=3, reason="Writing files can sometimes be unpredictable") def test_reload_app_on_local_module_change(page, autoreload, py_files): py_file, module = py_files diff --git a/panel/tests/ui/layout/test_card.py b/panel/tests/ui/layout/test_card.py index d46e1cebd2..caf481e729 100644 --- a/panel/tests/ui/layout/test_card.py +++ b/panel/tests/ui/layout/test_card.py @@ -4,8 +4,8 @@ from playwright.sync_api import expect -from panel import Card -from panel.tests.util import serve_component +from panel import Card, Row +from panel.tests.util import serve_component, wait_until from panel.widgets import FloatSlider, TextInput pytestmark = pytest.mark.ui @@ -182,3 +182,22 @@ def test_card_scrollable(page): serve_component(page, card) expect(page.locator('.card')).to_have_class('bk-panel-models-layout-Card card scrollable-vertical') + + +def test_card_widget_not_collapsed(page, card_components): + # Fixes https://github.com/holoviz/panel/issues/7045 + w1, w2 = card_components + card = Card(w1, header=Row(w2)) + + serve_component(page, card) + + text_input = page.locator('.bk-input[type="text"]') + expect(text_input).to_have_count(1) + + text_input.click() + + text_input.press("F") + text_input.press("Enter") + + wait_until(lambda: w2.value == 'F', page) + assert not card.collapsed diff --git a/panel/tests/ui/pane/test_markup.py b/panel/tests/ui/pane/test_markup.py index f2bae01304..6d246d76ee 100644 --- a/panel/tests/ui/pane/test_markup.py +++ b/panel/tests/ui/pane/test_markup.py @@ -82,12 +82,28 @@ def test_markdown_pane_visible_toggle(page): serve_component(page, md) - assert page.locator(".markdown").locator("div").text_content() == 'Initial\n' - assert not page.locator(".markdown").locator("div").is_visible() + expect(page.locator(".markdown").locator("div")).to_have_text('Initial\n') + expect(page.locator(".markdown").locator("div")).not_to_be_visible() md.visible = True - wait_until(lambda: page.locator(".markdown").locator("div").is_visible(), page) + expect(page.locator(".markdown").locator("div")).to_be_visible() + + +def test_markdown_pane_stream(page): + md = Markdown('Empty', enable_streaming=True) + + serve_component(page, md) + + expect(page.locator('.markdown')).to_have_text('Empty') + + md.object = '' + for i in range(1000): + md.object += str(i) + + assert md.object == ''.join(map(str, range(1000))) + expect(page.locator('.markdown')).to_have_text(md.object) + def test_html_model_no_stylesheet(page): # regression test for https://github.com/holoviz/holoviews/issues/5963 diff --git a/panel/tests/ui/test_custom.py b/panel/tests/ui/test_custom.py index d8c2ef406d..81769b321f 100644 --- a/panel/tests/ui/test_custom.py +++ b/panel/tests/ui/test_custom.py @@ -11,6 +11,8 @@ AnyWidgetComponent, Child, Children, JSComponent, ReactComponent, ) from panel.layout import Row +from panel.layout.base import ListLike +from panel.pane import Markdown from panel.tests.util import serve_component, wait_until pytestmark = pytest.mark.ui @@ -235,10 +237,13 @@ class JSChild(JSComponent): child = Child() + render_count = param.Integer(default=0) + _esm = """ export function render({ model }) { const button = document.createElement('button') button.appendChild(model.get_child('child')) + model.render_count += 1 return button }""" @@ -247,8 +252,11 @@ class ReactChild(ReactComponent): child = Child() + render_count = param.Integer(default=0) + _esm = """ export function render({ model }) { + model.render_count += 1 return }""" @@ -265,47 +273,118 @@ def test_child(page, component): expect(page.locator('button')).to_have_text('A different Markdown pane!') + wait_until(lambda: example.render_count == (2 if component is JSChild else 1), page) + -class JSChildren(JSComponent): +class JSChildren(ListLike, JSComponent): - children = Children() + objects = Children() + + render_count = param.Integer(default=0) _esm = """ export function render({ model }) { const div = document.createElement('div') div.id = "container" - div.append(...model.get_child('children')) + div.append(...model.get_child('objects')) + model.render_count += 1 return div }""" -class ReactChildren(ReactComponent): +class JSChildrenNoReturn(JSChildren): + + _esm = """ + export function render({ model, view }) { + const div = document.createElement('div') + div.id = "container" + div.append(...model.get_child('objects')) + view.container.replaceChildren(div) + model.render_count += 1 + }""" + - children = Children() +class ReactChildren(ListLike, ReactComponent): + + objects = Children() + + render_count = param.Integer(default=0) _esm = """ export function render({ model }) { - return
{model.get_child("children")}
+ model.render_count += 1 + return
{model.get_child("objects")}
}""" -@pytest.mark.parametrize('component', [JSChildren, ReactChildren]) +@pytest.mark.parametrize('component', [JSChildren, JSChildrenNoReturn, ReactChildren]) def test_children(page, component): - example = component(children=['A Markdown pane!']) + example = component(objects=['A Markdown pane!']) serve_component(page, example) expect(page.locator('#container')).to_have_text('A Markdown pane!') - example.children = ['A different Markdown pane!'] + example.objects = ['A different Markdown pane!'] expect(page.locator('#container')).to_have_text('A different Markdown pane!') - example.children = ['
1
', '
2
'] + example.objects = ['
1
', '
2
'] expect(page.locator('.foo').nth(0)).to_have_text('1') expect(page.locator('.foo').nth(1)).to_have_text('2') + page.wait_for_timeout(400) + + assert example.render_count == (3 if issubclass(component, JSChildren) else 2) + + +@pytest.mark.parametrize('component', [JSChildren, JSChildrenNoReturn, ReactChildren]) +def test_children_add_and_remove_without_error(page, component): + example = component(objects=['A Markdown pane!']) + + msgs, _ = serve_component(page, example) + + expect(page.locator('#container')).to_have_text('A Markdown pane!') + + example.append('A different Markdown pane!') + example.pop(-1) + + expect(page.locator('#container')).to_have_text('A Markdown pane!') + + expect(page.locator('.markdown')).to_have_count(1) + + page.wait_for_timeout(500) + + assert [msg for msg in msgs if msg.type == 'error' and 'favicon' not in msg.location['url']] == [] + + +@pytest.mark.parametrize('component', [JSChildren, JSChildrenNoReturn, ReactChildren]) +def test_children_append_without_rerender(page, component): + child = JSChild(child=Markdown( + 'A Markdown pane!', css_classes=['first'] + )) + example = component(objects=[child]) + + serve_component(page, example) + + expect(page.locator('.first')).to_have_text('A Markdown pane!') + + wait_until(lambda: child.render_count == 1, page) + + example.objects = example.objects+[Markdown( + 'A different Markdown pane!', css_classes=['second'] + )] + + expect(page.locator('.second')).to_have_text('A different Markdown pane!') + + page.wait_for_timeout(400) + + assert child.render_count == 1 + assert example.render_count == 2 + + + JS_CODE_BEFORE = """ export function render() { const h1 = document.createElement('h1') diff --git a/panel/tests/ui/widgets/test_codeeditor.py b/panel/tests/ui/widgets/test_codeeditor.py new file mode 100644 index 0000000000..1df565fcd4 --- /dev/null +++ b/panel/tests/ui/widgets/test_codeeditor.py @@ -0,0 +1,81 @@ +import sys + +import pytest + +pytest.importorskip("playwright") + +from playwright.sync_api import expect + +from panel.tests.util import serve_component, wait_until +from panel.widgets import CodeEditor + +pytestmark = pytest.mark.ui + + +def test_code_editor_on_keyup(page): + + editor = CodeEditor(value="print('Hello World!')", on_keyup=True) + serve_component(page, editor) + ace_input = page.locator(".ace_content") + expect(ace_input).to_have_count(1) + ace_input.click() + + page.keyboard.press("Enter") + page.keyboard.type('print("Hello Panel!")') + + expect(page.locator(".ace_content")).to_have_text("print('Hello World!')\nprint(\"Hello Panel!\")", use_inner_text=True) + wait_until(lambda: editor.value_input == "print('Hello World!')\nprint(\"Hello Panel!\")", page) + assert editor.value == "print('Hello World!')\nprint(\"Hello Panel!\")" + + # clear the editor + editor.value = "" + expect(page.locator(".ace_content")).to_have_text("", use_inner_text=True) + assert editor.value == "" + assert editor.value_input == "" + + # enter Hello UI + ace_input.click() + page.keyboard.type('print("Hello UI!")') + expect(page.locator(".ace_content")).to_have_text("print(\"Hello UI!\")", use_inner_text=True) + + wait_until(lambda: editor.value == "print(\"Hello UI!\")", page) + + +def test_code_editor_not_on_keyup(page): + + editor = CodeEditor(value="print('Hello World!')", on_keyup=False) + serve_component(page, editor) + ace_input = page.locator(".ace_content") + expect(ace_input).to_have_count(1) + ace_input.click() + + page.keyboard.press("Enter") + page.keyboard.type('print("Hello Panel!")') + + expect(page.locator(".ace_content")).to_have_text("print('Hello World!')\nprint(\"Hello Panel!\")", use_inner_text=True) + wait_until(lambda: editor.value_input == "print('Hello World!')\nprint(\"Hello Panel!\")") + assert editor.value == "print('Hello World!')" + + # page click outside the editor; sync the value + page.locator("body").click() + assert editor.value_input == "print('Hello World!')\nprint(\"Hello Panel!\")" + wait_until(lambda: editor.value == "print('Hello World!')\nprint(\"Hello Panel!\")") + + # clear the editor + editor.value = "" + expect(page.locator(".ace_content")).to_have_text("", use_inner_text=True) + assert editor.value == "" + assert editor.value_input == "" + + # enter Hello UI + ace_input.click() + page.keyboard.type('print("Hello UI!")') + expect(page.locator(".ace_content")).to_have_text("print(\"Hello UI!\")", use_inner_text=True) + assert editor.value == "" + + ctrl_key = 'Meta' if sys.platform == 'darwin' else 'Control' + page.keyboard.down(ctrl_key) + page.keyboard.press("Enter") + page.keyboard.up(ctrl_key) + + wait_until(lambda: editor.value == "print(\"Hello UI!\")", page) diff --git a/panel/tests/ui/widgets/test_player.py b/panel/tests/ui/widgets/test_player.py new file mode 100644 index 0000000000..44616607e9 --- /dev/null +++ b/panel/tests/ui/widgets/test_player.py @@ -0,0 +1,124 @@ +import pytest + +pytest.importorskip("playwright") + +from playwright.sync_api import expect + +from panel.tests.util import serve_component, wait_until +from panel.widgets import Player + +pytestmark = pytest.mark.ui + + +def test_player_faster_click_shows_ms(page): + player = Player() + serve_component(page, player) + + faster_element = page.locator(".faster") + faster_element.click() + + wait_until(lambda: player.interval == 350) + assert faster_element.inner_text() == "2.9\nfps" + + wait_until(lambda: faster_element.inner_text() == "") + + +def test_player_slower_click_shows_ms(page): + player = Player() + serve_component(page, player) + + slower_element = page.locator(".slower") + slower_element.click() + + wait_until(lambda: player.interval == 714) + assert slower_element.inner_text() == "1.4\nfps" + + wait_until(lambda: slower_element.inner_text() == "") + + +def test_init(page): + player = Player() + serve_component(page, player) + + assert not page.is_visible('pn-player-value') + assert page.query_selector('.pn-player-value') is None + + +def test_show_value(page): + player = Player(show_value=True) + serve_component(page, player) + + wait_until(lambda: page.query_selector('.pn-player-value') is not None) + assert page.query_selector('.pn-player-value') is not None + + +def test_name(page): + player = Player(name='test') + serve_component(page, player) + + assert page.is_visible('label') + assert page.query_selector('.pn-player-value') is None + + name = page.locator('.pn-player-title:has-text("test")') + expect(name).to_have_count(1) + + +def test_value_align(page): + player = Player(name='test', value_align='end') + serve_component(page, player) + + name = page.locator('.pn-player-title:has-text("test")') + expect(name).to_have_css("text-align", "right") + + +def test_name_and_show_value(page): + player = Player(name='test', show_value=True) + serve_component(page, player) + + assert page.is_visible('label') + assert page.query_selector('.pn-player-value') is not None + + name = page.locator('.pn-player-title:has-text("test")') + expect(name).to_have_count(1) +def test_player_visible_buttons(page): + player = Player(visible_buttons=["play", "pause"]) + serve_component(page, player) + + assert page.is_visible(".play") + assert page.is_visible(".pause") + assert not page.is_visible(".reverse") + assert not page.is_visible(".first") + assert not page.is_visible(".previous") + assert not page.is_visible(".next") + assert not page.is_visible(".last") + assert not page.is_visible(".slower") + assert not page.is_visible(".faster") + + player.visible_buttons = ["first"] + expect(page.locator(".first")).to_be_visible() + assert not page.is_visible(".play") + assert not page.is_visible(".pause") + + +def test_player_visible_loop_options(page): + player = Player(visible_loop_options=["loop", "once"]) + serve_component(page, player) + + assert page.is_visible(".loop") + assert page.is_visible(".once") + assert not page.is_visible(".reflect") + + player.visible_loop_options = ["reflect"] + expect(page.locator(".reflect")).to_be_visible() + assert not page.is_visible(".loop") + assert not page.is_visible(".once") + + +def test_player_scale_buttons(page): + player = Player(scale_buttons=2) + serve_component(page, player) + + expect(page.locator(".play")).to_have_attribute( + "style", + "text-align: center; flex-grow: 2; margin: 2px; transform: scale(2); max-width: 50px;", + ) diff --git a/panel/tests/ui/widgets/test_tabulator.py b/panel/tests/ui/widgets/test_tabulator.py index 09b5b61c8e..4e24683a1c 100644 --- a/panel/tests/ui/widgets/test_tabulator.py +++ b/panel/tests/ui/widgets/test_tabulator.py @@ -24,24 +24,11 @@ from panel.layout.base import Column from panel.models.tabulator import _TABULATOR_THEMES_MAPPING from panel.tests.util import get_ctrl_modifier, serve_component, wait_until -from panel.widgets import Select, Tabulator +from panel.widgets import Select, Tabulator, TextInput pytestmark = pytest.mark.ui -@pytest.fixture -def df_mixed(): - df = pd.DataFrame({ - 'int': [1, 2, 3, 4], - 'float': [3.14, 6.28, 9.42, -2.45], - 'str': ['A', 'B', 'C', 'D'], - 'bool': [True, True, True, False], - 'date': [dt.date(2019, 1, 1), dt.date(2020, 1, 1), dt.date(2020, 1, 10), dt.date(2019, 1, 10)], - 'datetime': [dt.datetime(2019, 1, 1, 10), dt.datetime(2020, 1, 1, 12), dt.datetime(2020, 1, 10, 13), dt.datetime(2020, 1, 15, 13)] - }, index=['idx0', 'idx1', 'idx2', 'idx3']) - return df - - @pytest.fixture(scope='session') def df_mixed_as_string(): return """index @@ -177,13 +164,14 @@ def test_tabulator_value_changed(page, df_mixed): serve_component(page, widget) + expect(page.locator('.pnx-tabulator.tabulator')).to_have_count(1) + df_mixed.loc['idx0', 'str'] = 'AA' # Need to trigger the value as the dataframe was modified # in place which is not detected. widget.param.trigger('value') - wait_until(lambda: page.locator('text="AA"') is not None, page) - changed_cell = page.locator('text="AA"') - expect(changed_cell).to_have_count(1) + + expect(page.locator('text="AA"')).to_have_count(1) def test_tabulator_disabled(page, df_mixed): @@ -1042,7 +1030,6 @@ def test_tabulator_frozen_rows(page): assert Y_bb == page.locator('text="Y"').bounding_box() -@pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3669') def test_tabulator_patch_no_horizontal_rescroll(page, df_mixed): widths = 100 width = int(((df_mixed.shape[1] + 1) * widths) / 2) @@ -1062,8 +1049,7 @@ def test_tabulator_patch_no_horizontal_rescroll(page, df_mixed): # Catch a potential rescroll page.wait_for_timeout(400) # The table should keep the same scroll position - # This fails - assert bb == page.locator('text="tomodify"').bounding_box() + wait_until(lambda: bb == page.locator('text="tomodify"').bounding_box(), page) @pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3249') @@ -1111,9 +1097,11 @@ def test_tabulator_patch_no_height_resize(page): serve_component(page, app) + page.wait_for_timeout(100) + page.mouse.wheel(delta_x=0, delta_y=10000) at_bottom_script = """ - isAtBottom => (window.innerHeight + window.scrollY) >= document.body.scrollHeight; + () => Math.round(window.innerHeight + window.scrollY) === document.body.scrollHeight """ wait_until(lambda: page.evaluate(at_bottom_script), page) @@ -1122,16 +1110,11 @@ def test_tabulator_patch_no_height_resize(page): # Give it some time to potentially "re-scroll" page.wait_for_timeout(400) - wait_until(lambda: page.evaluate(at_bottom_script), page) + wait_until(lambda: page.locator('.pnx-tabulator').evaluate(at_bottom_script), page) @pytest.mark.parametrize( - 'pagination', - ( - pytest.param('local', marks=pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3553')), - pytest.param('remote', marks=pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3553')), - None, - ) + 'pagination', ('local', 'remote', None) ) def test_tabulator_header_filter_no_horizontal_rescroll(page, df_mixed, pagination): widths = 100 @@ -1148,9 +1131,14 @@ def test_tabulator_header_filter_no_horizontal_rescroll(page, df_mixed, paginati serve_component(page, widget) + page.wait_for_timeout(100) + header = page.locator(f'text="{col_name}"') # Scroll to the right header.scroll_into_view_if_needed() + + page.wait_for_timeout(100) + bb = header.bounding_box() header = page.locator('input[type="search"]') @@ -1159,10 +1147,10 @@ def test_tabulator_header_filter_no_horizontal_rescroll(page, df_mixed, paginati header.press('Enter') # Wait to catch a potential rescroll - page.wait_for_timeout(400) + page.wait_for_timeout(500) # The table should keep the same scroll position, this fails - assert page.locator(f'text="{col_name}"').bounding_box() == bb + wait_until(lambda: page.locator(f'text="{col_name}"').bounding_box() == bb, page) def test_tabulator_header_filter_always_visible(page, df_mixed): @@ -1587,6 +1575,34 @@ def test_tabulator_row_content_expand_from_python_after(page, df_mixed): expect(page.locator('text="►"')).to_have_count(len(df_mixed)) +def test_tabulator_row_content_expand_after_filtered(page, df_mixed): + table = Tabulator(df_mixed, row_content=lambda e: f"Hello {e.int}", header_filters=True) + + serve_component(page, table) + + idx_filter = page.locator('.tabulator-col').nth(2).locator('input[type="search"]') + idx_filter.click() + idx_filter.fill('idx1') + idx_filter.press('Enter') + + rows = page.locator('.tabulator-row') + + expect(rows).to_have_count(1) + + page.locator('.tabulator-row').nth(0).locator('.tabulator-cell').nth(1).click() + + expect(page.locator('.markdown')).to_have_text('Hello 2') + + idx_filter.click() + idx_filter.fill('') + idx_filter.press('Enter') + + expect(rows).to_have_count(4) + + expect(rows.nth(0).locator('.markdown')).to_have_count(0) + expect(rows.nth(1).locator('.markdown')).to_have_text('Hello 2') + + def test_tabulator_groups(page, df_mixed): widget = Tabulator( df_mixed, @@ -1981,7 +1997,6 @@ def test_tabulator_header_filters_default(page, df_mixed, cols): ([0, 1], 'input[type="number"]'), (np.array([0, 1], dtype=np.uint64), 'input[type="number"]'), ([0.1, 1.1], 'input[type="number"]'), - # ([True, False], 'input[type="checkbox"]'), # Pandas cannot have boolean indexes apparently ), ) def test_tabulator_header_filters_default_index(page, index, expected_selector): @@ -2133,11 +2148,12 @@ def test_tabulator_streaming_default(page): serve_component(page, widget) + page.wait_for_timeout(100) + expect(page.locator('.tabulator-row')).to_have_count(len(df)) height_start = page.locator('.pnx-tabulator.tabulator').bounding_box()['height'] - def stream_data(): widget.stream(df) # follow is True by default @@ -2152,6 +2168,24 @@ def stream_data(): assert page.locator('.pnx-tabulator.tabulator').bounding_box()['height'] > height_start +@pytest.mark.parametrize('pagination', ['remote', 'local']) +def test_tabulator_streaming_follow_pagination(page, pagination): + df = pd.DataFrame(np.random.random((3, 2)), columns=['A', 'B']) + widget = Tabulator(df, pagination=pagination, page_size=3) + + serve_component(page, widget) + + expect(page.locator('.tabulator-row')).to_have_count(len(df)) + + widget.stream(df) + + expect(page.locator('.tabulator-page.active')).to_have_text('2') + + widget.stream(df) + + expect(page.locator('.tabulator-page.active')).to_have_text('3') + + def test_tabulator_streaming_no_follow(page): nrows1 = 10 arr = np.random.randint(10, 20, (nrows1, 2)) @@ -2162,11 +2196,16 @@ def test_tabulator_streaming_no_follow(page): serve_component(page, widget) + page.wait_for_timeout(100) + expect(page.locator('.tabulator-row')).to_have_count(len(df)) - assert page.locator('text="-1"').count() == 2 + expect(page.locator('text="-1"')).to_have_count(2) height_start = page.locator('.pnx-tabulator.tabulator').bounding_box()['height'] + scroll_top = page.locator('.pnx-tabulator.tabulator').evaluate("(el) => el.scrollTop") + assert scroll_top == 0 + recs = [] nrows2 = 5 def stream_data(): @@ -2180,16 +2219,16 @@ def stream_data(): repetitions = 3 state.add_periodic_callback(stream_data, period=100, count=repetitions) - # Explicit wait to make sure the periodic callback has completed + # Wait until data is updated + wait_until(lambda: len(widget.value) == nrows1 + repetitions * nrows2, page) + + # Explicit wait to make sure the periodic callback has propagated page.wait_for_timeout(500) - expect(page.locator('text="-1"')).to_have_count(2) - # As we're not in follow mode the last row isn't visible - # and seems to be out of reach to the selector. How visibility - # is used here seems brittle though, may need to be revisited. - expect(page.locator(f'text="{val[0]}"')).to_have_count(0) + scroll_top = page.locator('.pnx-tabulator.tabulator').evaluate("(el) => el.scrollTop") + assert scroll_top == 0 - assert len(widget.value) == nrows1 + repetitions * nrows2 + # Assert the data matches what we expect assert widget.current_view.equals(widget.value) assert page.locator('.pnx-tabulator.tabulator').bounding_box()['height'] == height_start @@ -2264,7 +2303,7 @@ def test_tabulator_styling_init(page, df_mixed): df_styled = ( df_mixed.style .apply(highlight_max, subset=['int']) - .applymap(color_false, subset=['bool']) + .map(color_false, subset=['bool']) ) widget = Tabulator(df_styled) @@ -2399,10 +2438,12 @@ def test_tabulator_sorters_set_after_init(page, df_mixed): serve_component(page, widget) + expect(page.locator('.pnx-tabulator.tabulator')).to_have_count(1) + widget.sorters = [{'field': 'int', 'dir': 'desc'}] sheader = page.locator('[aria-sort="descending"]:visible') - expect(sheader).to_have_count(1) + wait_until(lambda: expect(sheader).to_have_count(1), page) assert sheader.get_attribute('tabulator-field') == 'int' expected_df_sorted = df_mixed.sort_values('int', ascending=False) @@ -2552,19 +2593,23 @@ def test_tabulator_click_event_and_header_filters_and_streamed_data(page): str_header.press('Enter') wait_until(lambda: len(widget.filters) == 1, page) + page.wait_for_timeout(100) + # Stream data in ensuring that it does not mess up the index widget.stream(pd.DataFrame([('D', 'Y')], columns=['col1', 'col2'], index=[5])) + page.wait_for_timeout(100) + # Click on the last cell cell = page.locator('text="Z"') - cell.click() + cell.click(force=True) wait_until(lambda: len(values) == 1, page) # This cell was at index 4 in col2 of the original dataframe assert values[0] == ('col2', 4, 'Z') cell = page.locator('text="Y"') - cell.click() + cell.click(force=True) wait_until(lambda: len(values) == 2, page) # This cell was at index 5 in col2 of the original dataframe @@ -2873,7 +2918,7 @@ def test_tabulator_edit_event_integrations(page, sorter, python_filter, header_f expected_current_view = expected_current_view.query(f'{python_filter_col} == @python_filter_val') if header_filter == 'header_filter': expected_current_view = expected_current_view.query(f'{header_filter_col} == @header_filter_val') - assert widget.current_view.equals(expected_current_view) + pd.testing.assert_frame_equal(widget.current_view, expected_current_view) @pytest.mark.parametrize('sorter', ['sorter', 'no_sorter']) @@ -2950,7 +2995,6 @@ def test_tabulator_click_event_selection_integrations(page, sorter, python_filte assert widget.selected_dataframe.equals(expected_selected) -@pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3664') def test_tabulator_selection_sorters_on_init(page, df_mixed): widget = Tabulator(df_mixed, sorters=[{'field': 'int', 'dir': 'desc'}]) @@ -2966,7 +3010,6 @@ def test_tabulator_selection_sorters_on_init(page, df_mixed): assert widget.selected_dataframe.equals(expected_selected) -@pytest.mark.xfail(reason='https://github.com/holoviz/panel/issues/3664') def test_tabulator_selection_header_filter_unchanged(page): df = pd.DataFrame({ 'col1': list('XYYYYY'), @@ -3019,6 +3062,23 @@ def test_tabulator_selection_header_filter_changed(page): expected_selected = df.iloc[selection, :] assert widget.selected_dataframe.equals(expected_selected) +def test_tabulator_sorter_not_reversed_after_init(page): + df = pd.DataFrame({ + 'col1': [1, 2, 3, 4], + 'col2': [1, 4, 3, 2], + }) + + sorters = [ + {'field': 'col1', 'dir': 'desc'}, + {'field': 'col2', 'dir': 'asc'} + ] + table = Tabulator(df, sorters=sorters) + + serve_component(page, table) + + expect(page.locator('.pnx-tabulator.tabulator')).to_have_count(1) + page.wait_for_timeout(300) + assert table.sorters == sorters def test_tabulator_loading_no_horizontal_rescroll(page, df_mixed): widths = 100 @@ -3370,6 +3430,176 @@ def test_tabulator_update_hidden_columns(page): ), page) +def test_tabulator_remote_pagination_auto_page_size_grow(page, df_mixed): + nrows, ncols = df_mixed.shape + widget = Tabulator(df_mixed, pagination='remote', initial_page_size=1, height=200) + + serve_component(page, widget) + + expect(page.locator('.tabulator-table')).to_have_count(1) + + wait_until(lambda: widget.page_size == 4, page) + + +def test_tabulator_remote_pagination_auto_page_size_shrink(page, df_mixed): + nrows, ncols = df_mixed.shape + widget = Tabulator(df_mixed, pagination='remote', initial_page_size=10, height=150) + + serve_component(page, widget) + + expect(page.locator('.tabulator-table')).to_have_count(1) + + wait_until(lambda: widget.page_size == 3, page) + + +@pytest.mark.parametrize('pagination', ['local', 'remote', None]) +def test_selection_indices_on_paginated_and_filtered_data(page, df_strings, pagination): + tbl = Tabulator( + df_strings, + disabled=True, + pagination=pagination, + page_size=6, + ) + + descr_filter = TextInput(name='descr', value='cut') + + def contains_filter(df, pattern=None): + if not pattern: + return df + return df[df.descr.str.contains(pattern, case=False)] + + filter_fn = param.bind(contains_filter, pattern=descr_filter) + tbl.add_filter(filter_fn) + + serve_component(page, tbl) + + expect(page.locator('.tabulator-table')).to_have_count(1) + + row = page.locator('.tabulator-row').nth(1) + row.click() + + wait_until(lambda: tbl.selection == [7], page) + + tbl.page_size = 2 + + page.locator('.tabulator-row').nth(0).click() + + wait_until(lambda: tbl.selection == [3], page) + + if pagination: + page.locator('.tabulator-pages > .tabulator-page').nth(1).click() + expect(page.locator('.tabulator-row')).to_have_count(1) + page.locator('.tabulator-row').nth(0).click() + else: + expect(page.locator('.tabulator-row')).to_have_count(3) + page.locator('.tabulator-row').nth(2).click() + + wait_until(lambda: tbl.selection == [8], page) + + descr_filter.value = '' + + wait_until(lambda: tbl.selection == [8], page) + + +@pytest.mark.parametrize('pagination', ['local', 'remote', None]) +def test_selection_indices_on_paginated_sorted_and_filtered_data(page, df_strings, pagination): + tbl = Tabulator( + df_strings, + disabled=True, + pagination=pagination, + page_size=6, + ) + + descr_filter = TextInput(name='descr', value='cut') + + def contains_filter(df, pattern=None): + if not pattern: + return df + return df[df.descr.str.contains(pattern, case=False)] + + filter_fn = param.bind(contains_filter, pattern=descr_filter) + tbl.add_filter(filter_fn) + + serve_component(page, tbl) + + expect(page.locator('.tabulator-table')).to_have_count(1) + + page.locator('.tabulator-col-title-holder').nth(3).click() + + # Wait for sorting + page.wait_for_timeout(100) + + row = page.locator('.tabulator-row').nth(1) + row.click() + + wait_until(lambda: tbl.selection == [8], page) + + tbl.page_size = 2 + + page.locator('.tabulator-col-title-holder').nth(3).click() + + # Wait for sorting + page.wait_for_timeout(100) + + page.locator('.tabulator-row').nth(0).click() + + wait_until(lambda: tbl.selection == [3], page) + + if pagination: + page.locator('.tabulator-pages > .tabulator-page').nth(1).click() + expect(page.locator('.tabulator-row')).to_have_count(1) + page.locator('.tabulator-row').nth(0).click() + else: + expect(page.locator('.tabulator-row')).to_have_count(3) + page.locator('.tabulator-row').nth(2).click() + + wait_until(lambda: tbl.selection == [7], page) + + descr_filter.value = '' + + wait_until(lambda: tbl.selection == [7], page) + + +@pytest.mark.parametrize('pagination', ['remote', 'local', None]) +def test_range_selection_on_sorted_data_downward(page, pagination): + df = pd.DataFrame({'a': [1, 3, 2, 4, 5, 6, 7, 8, 9], 'b': [6, 5, 6, 7, 7, 7, 7, 7, 7]}) + table = Tabulator(df, disabled=True, pagination=pagination) + + serve_component(page, table) + + page.locator('.tabulator-col-title-holder').nth(2).click() + + page.wait_for_timeout(100) + + page.locator('.tabulator-row').nth(0).click() + + page.keyboard.down('Shift') + + page.locator('.tabulator-row').nth(1).click() + + wait_until(lambda: table.selection == [0, 2], page) + + +@pytest.mark.parametrize('pagination', ['remote', 'local', None]) +def test_range_selection_on_sorted_data_upward(page, pagination): + df = pd.DataFrame({'a': [1, 3, 2, 4, 5, 6, 7, 8, 9], 'b': [6, 5, 6, 7, 7, 7, 7, 7, 7]}) + table = Tabulator(df, disabled=True, pagination=pagination, page_size=3) + + serve_component(page, table) + + page.locator('.tabulator-col-title-holder').nth(2).click() + + page.wait_for_timeout(100) + + page.locator('.tabulator-row').nth(1).click() + + page.keyboard.down('Shift') + + page.locator('.tabulator-row').nth(0).click() + + wait_until(lambda: table.selection == [2, 0], page) + + class Test_RemotePagination: @pytest.fixture(autouse=True) @@ -3389,6 +3619,7 @@ def check_selected(self, page, expected, ui_count=None): ui_count = len(expected) expect(page.locator('.tabulator-selected')).to_have_count(ui_count) + wait_until(lambda: self.widget.selection == expected, page) @contextmanager diff --git a/panel/tests/util.py b/panel/tests/util.py index 981943633e..66f7938258 100644 --- a/panel/tests/util.py +++ b/panel/tests/util.py @@ -442,8 +442,7 @@ def serve_forever(httpd): with httpd: httpd.serve_forever() - thread = Thread(target=serve_forever, args=(httpd, )) - thread.setDaemon(True) + thread = Thread(target=serve_forever, args=(httpd, ), daemon=True) thread.start() return httpd, address diff --git a/panel/tests/widgets/test_codeeditor.py b/panel/tests/widgets/test_codeeditor.py index d85c8650de..fba2924c57 100644 --- a/panel/tests/widgets/test_codeeditor.py +++ b/panel/tests/widgets/test_codeeditor.py @@ -12,3 +12,14 @@ def test_ace(document, comm): # Try changes editor._process_events({"value": "Hi there!"}) assert editor.value == "Hi there!" + + +def test_ace_input(document, comm): + editor = CodeEditor(value="", language="python") + editor.value = "Hello World!" + assert editor.value == "Hello World!" + assert editor.value_input == "Hello World!" + + editor.value = "" + assert editor.value == "" + assert editor.value_input == "" diff --git a/panel/tests/widgets/test_input.py b/panel/tests/widgets/test_input.py index 76a1897cff..54e1d9ebbe 100644 --- a/panel/tests/widgets/test_input.py +++ b/panel/tests/widgets/test_input.py @@ -244,7 +244,6 @@ def test_literal_input(document, comm): with pytest.raises(ValueError): literal.value = [] - def test_static_text(document, comm): text = StaticText(value='ABC', name='Text:') @@ -260,6 +259,31 @@ def test_static_text(document, comm): text.value = 'Text:: ABC' assert widget.text == 'Text:: ABC' +def test_static_text_no_sync(document, comm): + text = StaticText(value='ABC', name='Text:') + + widget = text.get_root(document, comm=comm) + + widget.text = 'CBA' + assert text.value == 'ABC' + +def test_static_text_empty(document, comm): + + text = StaticText(name='Text:') + + widget = text.get_root(document, comm=comm) + + assert widget.text == 'Text:: ' + +def test_static_text_repr(document, comm): + + text = StaticText(value=StaticText, name='Text:') + + widget = text.get_root(document, comm=comm) + + assert widget.text == 'Text:: <class 'panel.widgets.input.StaticText'>' + + def test_text_input(document, comm): diff --git a/panel/tests/widgets/test_player.py b/panel/tests/widgets/test_player.py index d891508fb0..cf2ece523f 100644 --- a/panel/tests/widgets/test_player.py +++ b/panel/tests/widgets/test_player.py @@ -20,3 +20,15 @@ def test_discrete_player(document, comm): discrete_player.value = 100 assert widget.value == 3 + + +def test_player_loop_policy_not_in_loop_options(document, comm): + player = DiscretePlayer(name='Player', loop_policy='once', visible_loop_options=['loop', 'reflect']) + assert player.loop_policy == 'loop' + assert player.visible_loop_options == ['loop', 'reflect'] + + +def test_player_loop_policy_with_no_loop_options(document, comm): + player = DiscretePlayer(name='Player', loop_policy='loop', visible_loop_options=[]) + assert player.loop_policy == 'loop' + assert player.visible_loop_options == [] diff --git a/panel/tests/widgets/test_tables.py b/panel/tests/widgets/test_tables.py index a3d904d64f..38a781283f 100644 --- a/panel/tests/widgets/test_tables.py +++ b/panel/tests/widgets/test_tables.py @@ -5,6 +5,7 @@ import numpy as np import pandas as pd +import param import pytest from bokeh.models.widgets.tables import ( @@ -238,6 +239,36 @@ def test_none_table(document, comm): assert model.source.data == {} +def test_tabulator_none_value(document, comm): + table = Tabulator(value=None) + assert table.indexes == [] + + model = table.get_root(document, comm) + + assert model.source.data == {} + assert model.columns == [] + + +def test_tabulator_update_none_value(document, comm, df_mixed): + table = Tabulator(value=df_mixed) + + model = table.get_root(document, comm) + + table.value = None + + assert model.source.data == {} + assert model.columns == [] + + +def test_tabulator_selection_resets(): + df = makeMixedDataFrame() + table = Tabulator(df, selection=list(range(len(df)))) + + for i in reversed(range(len(df))): + table.value = df.iloc[:i] + assert table.selection == list(range(i)) + + def test_tabulator_selected_dataframe(): df = makeMixedDataFrame() table = Tabulator(df, selection=[0, 2]) @@ -279,6 +310,44 @@ def test_tabulator_multi_index_remote_pagination(document, comm): assert np.array_equal(model.source.data['C'], np.array(['foo1', 'foo2', 'foo3'])) +def test_tabulator_multi_index_columns(document, comm): + level_1 = ['A', 'A', 'A', 'B', 'B', 'B'] + level_2 = ['one', 'one', 'two', 'two', 'three', 'three'] + level_3 = ['X', 'Y', 'X', 'Y', 'X', 'Y'] + + # Combine these into a MultiIndex + multi_index = pd.MultiIndex.from_arrays([level_1, level_2, level_3], names=['Level 1', 'Level 2', 'Level 3']) + + # Create a DataFrame with this MultiIndex as columns + df = pd.DataFrame(np.random.randn(4, 6), columns=multi_index) + + table = Tabulator(df) + + model = table.get_root(document, comm) + + assert model.configuration['columns'] == [ + {'field': 'index', 'sorter': 'number'}, + {'title': 'A', 'columns': [ + {'title': 'one', 'columns': [ + {'field': 'A_one_X', 'sorter': 'number'}, + {'field': 'A_one_Y', 'sorter': 'number'}, + ]}, + {'title': 'two', 'columns': [ + {'field': 'A_two_X', 'sorter': 'number'} + ]}, + ]}, + {'title': 'B', 'columns': [ + {'title': 'two', 'columns': [ + {'field': 'B_two_Y', 'sorter': 'number'}, + ]}, + {'title': 'three', 'columns': [ + {'field': 'B_three_X', 'sorter': 'number'}, + {'field': 'B_three_Y', 'sorter': 'number'} + ]}, + ]} + ] + + def test_tabulator_expanded_content(document, comm): df = makeMixedDataFrame() @@ -308,6 +377,99 @@ def test_tabulator_expanded_content(document, comm): assert row2.text == "<pre>2.0</pre>" +def test_tabulator_remote_paginated_expanded_content(document, comm): + df = makeMixedDataFrame() + + table = Tabulator( + df, expanded=[0, 4], row_content=lambda r: r.A, pagination='remote', page_size=3 + ) + + model = table.get_root(document, comm) + + assert len(model.children) == 1 + assert 0 in model.children + row0 = model.children[0] + assert row0.text == "<pre>0.0</pre>" + + table.page = 2 + + assert len(model.children) == 1 + assert 1 in model.children + row1 = model.children[1] + assert row1.text == "<pre>4.0</pre>" + + +def test_tabulator_remote_sorted_paginated_expanded_content(document, comm): + df = makeMixedDataFrame() + + table = Tabulator( + df, expanded=[0, 1], row_content=lambda r: r.A, pagination='remote', page_size=2, + sorters = [{'field': 'A', 'sorter': 'number', 'dir': 'desc'}], page=3 + ) + + model = table.get_root(document, comm) + + assert len(model.children) == 1 + assert 0 in model.children + row0 = model.children[0] + assert row0.text == "<pre>0.0</pre>" + + table.page = 2 + + assert len(model.children) == 1 + assert 1 in model.children + row1 = model.children[1] + assert row1.text == "<pre>1.0</pre>" + + table.expanded = [0, 1, 2] + + assert len(model.children) == 2 + assert 0 in model.children + row0 = model.children[0] + assert row0.text == "<pre>2.0</pre>" + + +@pytest.mark.parametrize('pagination', ['local', 'remote', None]) +def test_tabulator_filtered_expanded_content(document, comm, pagination): + df = makeMixedDataFrame() + + table = Tabulator( + df, + expanded=[0, 1, 2, 3], + filters=[{'field': 'B', 'sorter': 'number', 'type': '=', 'value': '1.0'}], + pagination=pagination, + row_content=lambda r: r.A, + ) + + model = table.get_root(document, comm) + + assert len(model.children) == 2 + + assert 0 in model.children + row0 = model.children[0] + assert row0.text == "<pre>1.0</pre>" + + assert 1 in model.children + row1 = model.children[1] + assert row1.text == "<pre>3.0</pre>" + + model.expanded = [0] + assert table.expanded == [1] + + table.filters = [{'field': 'B', 'sorter': 'number', 'type': '=', 'value': '0'}] + + assert not model.expanded + assert table.expanded == [1] + + table.expanded = [0, 1] + + assert len(model.children) == 1 + + assert 0 in model.children + row0 = model.children[0] + assert row0.text == "<pre>0.0</pre>" + + def test_tabulator_index_column(document, comm): df = pd.DataFrame({ 'int': [1, 2, 3], @@ -395,6 +557,8 @@ def test_tabulator_selected_and_filtered_dataframe(document, comm): table.add_filter('foo3', 'C') + assert table.selection == list(range(5)) + pd.testing.assert_frame_equal(table.selected_dataframe, df[df["C"] == "foo3"]) table.remove_filter('foo3') @@ -403,7 +567,46 @@ def test_tabulator_selected_and_filtered_dataframe(document, comm): table.add_filter('foo3', 'C') - assert table.selection == [0] + assert table.selection == [0, 1, 2] + + +@pytest.mark.parametrize('pagination', ['local', 'remote', None]) +def test_selection_indices_on_remote_paginated_and_filtered_data(document, comm, df_strings, pagination): + tbl = Tabulator( + df_strings, + pagination=pagination, + page_size=6, + show_index=False, + height=300, + width=400 + ) + + descr_filter = TextInput(name='descr') + + def contains_filter(df, pattern=None): + if not pattern: + return df + return df[df.descr.str.contains(pattern, case=False)] + + filter_fn = param.bind(contains_filter, pattern=descr_filter) + tbl.add_filter(filter_fn) + + model = tbl.get_root(document, comm) + + descr_filter.value = 'cut' + + pd.testing.assert_frame_equal( + tbl.current_view, df_strings[df_strings.descr.str.contains('cut', case=False)] + ) + + model.source.selected.indices = [0, 2] + + assert tbl.selection == [3, 8] + + model.page_size = 2 + model.source.selected.indices = [1] + + assert tbl.selection == [7] def test_tabulator_config_defaults(document, comm): @@ -460,7 +663,7 @@ def test_tabulator_header_filters_column_config_list(document, comm): {'field': 'index', 'sorter': 'number'}, {'field': 'A', 'sorter': 'number'}, {'field': 'B', 'sorter': 'number'}, - {'field': 'C', 'headerFilter': 'list', 'headerFilterParams': {'valuesLookup': True}}, + {'field': 'C', 'headerFilter': 'list', 'headerFilterParams': {'valuesLookup': True}, 'headerFilterFunc': 'in'}, {'field': 'D', 'sorter': 'timestamp'} ] assert model.configuration['selectable'] == True @@ -479,8 +682,8 @@ def test_tabulator_header_filters_column_config_select_autocomplete_backwards_co {'field': 'index', 'sorter': 'number'}, {'field': 'A', 'sorter': 'number'}, {'field': 'B', 'sorter': 'number'}, - {'field': 'C', 'headerFilter': 'list', 'headerFilterParams': {'valuesLookup': True}}, - {'field': 'D', 'headerFilter': 'list', 'headerFilterParams': {'valuesLookup': True}, 'sorter': 'timestamp'}, + {'field': 'C', 'headerFilter': 'list', 'headerFilterParams': {'valuesLookup': True}, 'headerFilterFunc': 'in'}, + {'field': 'D', 'headerFilter': 'list', 'headerFilterParams': {'valuesLookup': True}, 'sorter': 'timestamp', 'headerFilterFunc': 'in'}, ] assert model.configuration['selectable'] == True @@ -685,18 +888,42 @@ def test_tabulator_selectable_rows(document, comm): assert model.selectable_rows == [3, 4] -@pytest.mark.xfail(reason='See https://github.com/holoviz/panel/issues/3644') def test_tabulator_selectable_rows_nonallowed_selection_error(document, comm): df = makeMixedDataFrame() - table = Tabulator(df, selectable_rows=lambda df: [1]) + table = Tabulator(df, selectable_rows=lambda df: [0]) model = table.get_root(document, comm) + assert model.selectable_rows == [0] - assert model.selectable_rows == [1] + err_msg = ( + "Values in 'selection' must not have values " + "which are not available with 'selectable_rows'." + ) - # - with pytest.raises(ValueError): - table.selection = [0] + # This is available with selectable rows + table.selection = [] + assert table.selection == [] + table.selection = [0] + assert table.selection == [0] + + # This is not and should raise the error + with pytest.raises(ValueError, match=err_msg): + table.selection = [1] + assert table.selection == [0] + with pytest.raises(ValueError, match=err_msg): + table.selection = [0, 1] + assert table.selection == [0] + + # No selectable_rows everything should work + table = Tabulator(df) + table.selection = [] + assert table.selection == [] + table.selection = [0] + assert table.selection == [0] + table.selection = [1] + assert table.selection == [1] + table.selection = [0, 1] + assert table.selection == [0, 1] def test_tabulator_pagination(document, comm): @@ -791,7 +1018,7 @@ def test_tabulator_styling(document, comm): def high_red(value): return 'color: red' if value > 2 else 'color: black' - table.style.applymap(high_red, subset=['A']) + table.style.map(high_red, subset=['A']) model = table.get_root(document, comm) @@ -816,28 +1043,31 @@ def test_tabulator_empty_table(document, comm): assert table.value.shape == value_df.shape - def test_tabulator_sorters_unnamed_index(document, comm): df = pd.DataFrame(np.random.rand(10, 4)) + assert df.columns.dtype == np.int64 table = Tabulator(df) table.sorters = [{'field': 'index', 'sorter': 'number', 'dir': 'desc'}] + res = table.current_view + exp = df.sort_index(ascending=False) + exp.columns = exp.columns.astype(object) - pd.testing.assert_frame_equal( - table.current_view, - df.sort_index(ascending=False) - ) + pd.testing.assert_frame_equal(res, exp) + assert df.columns.dtype == np.int64 def test_tabulator_sorters_int_name_column(document, comm): df = pd.DataFrame(np.random.rand(10, 4)) + assert df.columns.dtype == np.int64 table = Tabulator(df) table.sorters = [{'field': '0', 'dir': 'desc'}] + res = table.current_view + exp = df.sort_values([0], ascending=False) + exp.columns = exp.columns.astype(object) - pd.testing.assert_frame_equal( - table.current_view, - df.sort_values([0], ascending=False) - ) + pd.testing.assert_frame_equal(res, exp) + assert df.columns.dtype == np.int64 def test_tabulator_stream_series(document, comm): @@ -1212,6 +1442,14 @@ def test_tabulator_patch_with_filters(document, comm): table.value[col].values, expected_df[col] ) + table.filters = [] + + for col, values in model.source.data.items(): + expected = expected_df[col] + if col == 'D': + expected = expected.astype(np.int64) / 10e5 + np.testing.assert_array_equal(values, expected) + def test_tabulator_patch_with_sorters(document, comm): df = makeMixedDataFrame() table = Tabulator(df, sorters=[{'field': 'A', 'sorter': 'number', 'dir': 'desc'}]) @@ -2147,6 +2385,32 @@ def test_tabulator_styling_empty_dataframe(document, comm): } } +def test_tabulator_style_multi_index_dataframe(document, comm): + # See https://github.com/holoviz/panel/issues/6151 + arrays = [['A', 'A', 'B', 'B'], [1, 2, 1, 2]] + index = pd.MultiIndex.from_arrays(arrays, names=('Letters', 'Numbers')) + df = pd.DataFrame({ + 'Values': [1, 2, 3, 4], + 'X': [10, 20, 30, 40], + 'Y': [100, 200, 300, 400], + 'Z': [1000, 2000, 3000, 4000] + }, index=index) + + def color_func(vals): + return ["background-color: #ff0000;" for v in vals] + + tabulator = Tabulator(df, width=500, height=300) + tabulator.style.apply(color_func, subset = ['X']) + + model = tabulator.get_root(document, comm) + + assert model.cell_styles['data'] == { + 0: {4: [('background-color', '#ff0000')]}, + 1: {4: [('background-color', '#ff0000')]}, + 2: {4: [('background-color', '#ff0000')]}, + 3: {4: [('background-color', '#ff0000')]} + } + @mpl_available def test_tabulator_style_background_gradient_with_frozen_columns(document, comm): diff --git a/panel/theme/css/bootstrap.css b/panel/theme/css/bootstrap.css index 1630fd3dda..6a7bdbac5c 100644 --- a/panel/theme/css/bootstrap.css +++ b/panel/theme/css/bootstrap.css @@ -149,6 +149,38 @@ button.accordion-header { border-radius: 0; } +.card-button { + line-height: 0.9em; +} + +.card-title { + margin-bottom: 5px; +} + +:host(.card-title) h1 { + font-size: 2em; +} + +:host(.card-title) h2 { + font-size: 1.5em; +} + +:host(.card-title) h3 { + font-size: 1.17em; +} + +:host(.card-title) h4 { + font-size: 1em; +} + +:host(.card-title) h5 { + font-size: 0.83em; +} + +:host(.card-title) h6 { + font-size: 0.67em; +} + :host(.card-title) h1, :host(.card-title) h2, :host(.card-title) h3, diff --git a/panel/theme/css/fast.css b/panel/theme/css/fast.css index 232d482b2d..d41f103dc0 100644 --- a/panel/theme/css/fast.css +++ b/panel/theme/css/fast.css @@ -307,7 +307,7 @@ table.panel-df { background-color: var(--neutral-fill-input-rest); border: 1px solid var(--accent-fill-rest); border-radius: calc(var(--control-corner-radius) * 1px); - color: var(--neutral-foreground-rest); + color: var(--foreground-on-accent-rest); font-size: var(--type-ramp-base-font-size); height: calc( (var(--base-height-multiplier) + var(--density)) * var(--design-unit) * 1px diff --git a/panel/util/__init__.py b/panel/util/__init__.py index 3692ff2545..0a074121cd 100644 --- a/panel/util/__init__.py +++ b/panel/util/__init__.py @@ -180,13 +180,13 @@ def value_as_datetime(value): Retrieve the value tuple as a tuple of datetime objects. """ if isinstance(value, numbers.Number): - value = datetime.utcfromtimestamp(value / 1000) + value = datetime.fromtimestamp(value / 1000, tz=dt.timezone.utc).replace(tzinfo=None) return value def value_as_date(value): if isinstance(value, numbers.Number): - value = datetime.utcfromtimestamp(value / 1000).date() + value = datetime.fromtimestamp(value / 1000, tz=dt.timezone.utc).replace(tzinfo=None).date() elif isinstance(value, datetime): value = value.date() return value @@ -503,3 +503,21 @@ def safe_next(): if value is done: break yield value + + +def prefix_length(a: str, b: str) -> int: + """ + Searches for the length of overlap in the starting + characters of string b in a. Uses binary search + if b is not already a prefix of a. + """ + if a.startswith(b): + return len(b) + left, right = 0, min(len(a), len(b)) + while left < right: + mid = (left + right + 1) // 2 + if a.startswith(b[:mid]): + left = mid + else: + right = mid - 1 + return left diff --git a/panel/viewable.py b/panel/viewable.py index ccb35b085e..c75f61d20b 100644 --- a/panel/viewable.py +++ b/panel/viewable.py @@ -1093,7 +1093,7 @@ def __init__( ): ... - def __init__(self, /, default=Undefined, class_=Viewable, **params): + def __init__(self, /, default=Undefined, class_=Viewable, allow_refs=False, **params): if isinstance(class_, type) and not issubclass(class_, Viewable): raise TypeError( f"Child.class_ must be an instance of Viewable, not {type(class_)}." @@ -1103,7 +1103,10 @@ def __init__(self, /, default=Undefined, class_=Viewable, **params): raise TypeError( f"Child.class_ must be an instance of Viewable, not {invalid}." ) - super().__init__(default=self._transform_value(default), class_=class_, **params) + super().__init__( + default=self._transform_value(default), class_=class_, + allow_refs=allow_refs, **params + ) def _transform_value(self, val): if not isinstance(val, Viewable) and val not in (None, Undefined): diff --git a/panel/widgets/base.py b/panel/widgets/base.py index dd2b705f49..0ecd04102a 100644 --- a/panel/widgets/base.py +++ b/panel/widgets/base.py @@ -103,7 +103,7 @@ class Widget(Reactive, WidgetBase): __abstract = True - def __init__(self, **params): + def __init__(self, **params: Any): if 'name' not in params: params['name'] = '' if '_supports_embed' in params: diff --git a/panel/widgets/codeeditor.py b/panel/widgets/codeeditor.py index 0086bc298a..8e766afd85 100644 --- a/panel/widgets/codeeditor.py +++ b/panel/widgets/codeeditor.py @@ -40,6 +40,9 @@ class CodeEditor(Widget): language = param.String(default='text', doc="Language of the editor") + on_keyup = param.Boolean(default=True, doc=""" + Whether to update the value on every key press or only upon loss of focus / hotkeys.""") + print_margin = param.Boolean(default=False, doc=""" Whether to show the a print margin.""") @@ -49,9 +52,14 @@ class CodeEditor(Widget): theme = param.ObjectSelector(default="chrome", objects=list(ace_themes), doc="Theme of the editor") - value = param.String(default="", doc="State of the current code in the editor") + value = param.String(default="", doc=""" + State of the current code in the editor if `on_keyup`. Otherwise, only upon loss of focus, + i.e. clicking outside the editor, or pressing or .""") + + value_input = param.String(default="", doc=""" + State of the current code updated on every key press. Identical to `value` if `on_keyup`.""") - _rename: ClassVar[Mapping[str, str | None]] = {"value": "code", "name": None} + _rename: ClassVar[Mapping[str, str | None]] = {"value": "code", "value_input": "code_input", "name": None} def __init__(self, **params): if 'readonly' in params: @@ -64,6 +72,10 @@ def __init__(self, **params): ) self.jslink(self, readonly='disabled', bidirectional=True) + @param.depends("value", watch=True) + def _update_value_input(self): + self.value_input = self.value + def _get_model( self, doc: Document, root: Optional[Model] = None, parent: Optional[Model] = None, comm: Optional[Comm] = None diff --git a/panel/widgets/indicators.py b/panel/widgets/indicators.py index acd2a3ddfd..920e2546af 100644 --- a/panel/widgets/indicators.py +++ b/panel/widgets/indicators.py @@ -520,7 +520,7 @@ def _update_value_bounds(self): def _process_param_change(self, msg): msg = super()._process_param_change(msg) vmin, vmax = msg.pop('bounds', self.bounds) - msg['data'] = { + msg['data'] = data = { 'tooltip': { 'formatter': msg.pop('tooltip_format', self.tooltip_format) }, @@ -546,6 +546,13 @@ def _process_param_change(self, msg): } }] } + sm = self.sizing_mode + if 'stretch' in sm: + data['responsive'] = True + if 'width' in msg and ('both' in sm or 'width' in sm): + del msg['width'] + if 'height' in msg and ('both' in sm or 'height' in sm): + del msg['height'] colors = msg.pop('colors', self.colors) if colors: msg['data']['series'][0]['axisLine']['lineStyle']['color'] = colors diff --git a/panel/widgets/input.py b/panel/widgets/input.py index b461c45fe3..77b14c173c 100644 --- a/panel/widgets/input.py +++ b/panel/widgets/input.py @@ -33,7 +33,9 @@ DatetimePicker as _bkDatetimePicker, TextAreaInput as _bkTextAreaInput, TextInput as _BkTextInput, TimePicker as _BkTimePicker, ) -from ..util import lazy_load, param_reprs, try_datetime64_to_datetime +from ..util import ( + escape, lazy_load, param_reprs, try_datetime64_to_datetime, +) from .base import CompositeWidget, Widget if TYPE_CHECKING: @@ -429,7 +431,7 @@ class StaticText(Widget): """ value = param.Parameter(default=None, doc=""" - The current value""") + The current value to be displayed.""") _format: ClassVar[str] = '{title}: {value}' @@ -445,10 +447,22 @@ class StaticText(Widget): _widget_type: ClassVar[type[Model]] = _BkDiv + @property + def _linked_properties(self) -> tuple[str]: + return () + + def _init_params(self) -> dict[str, Any]: + return { + k: v for k, v in self.param.values().items() + if k in self._synced_params and (v is not None or k == 'value') + } + def _process_param_change(self, msg): msg = super()._process_param_change(msg) if 'text' in msg: - text = str(msg.pop('text')) + text = msg.pop('text') + if not isinstance(text, str): + text = escape("" if text is None else str(text)) partial = self._format.replace('{value}', '').format(title=self.name) if self.name: text = self._format.format(title=self.name, value=text.replace(partial, '')) diff --git a/panel/widgets/misc.py b/panel/widgets/misc.py index 61edb8e4d8..027ac2340e 100644 --- a/panel/widgets/misc.py +++ b/panel/widgets/misc.py @@ -61,7 +61,7 @@ def snapshot(self): Triggers a snapshot of the current VideoStream state to sync the widget value. """ - for ref, (m, _) in self._models.items(): + for ref, (m, _) in self._models.copy().items(): m.snapshot = not m.snapshot (self, root, doc, comm) = state._views[ref] if comm and 'embedded' not in root.tags: diff --git a/panel/widgets/player.py b/panel/widgets/player.py index 817ead241e..86a60dc74f 100644 --- a/panel/widgets/player.py +++ b/panel/widgets/player.py @@ -8,7 +8,10 @@ import param from ..config import config -from ..models.widgets import Player as _BkPlayer +from ..io.resources import CDN_DIST +from ..models.widgets import ( + DiscretePlayer as _BkDiscretePlayer, Player as _BkPlayer, +) from ..util import indexOf, isIn from .base import Widget from .select import SelectBase @@ -31,25 +34,53 @@ class PlayerBase(Widget): default='once', objects=['once', 'loop', 'reflect'], doc=""" Policy used when player hits last frame""") + preview_duration = param.Integer(default=1500, bounds=(0, None), doc=""" + Duration (in milliseconds) for showing the current FPS when clicking + the slower/faster buttons, before reverting to the icon.""") + show_loop_controls = param.Boolean(default=True, doc=""" Whether the loop controls radio buttons are shown""") + show_value = param.Boolean(default=False, doc=""" + Whether to show the widget value""") + step = param.Integer(default=1, doc=""" Number of frames to step forward and back by on each event.""") height = param.Integer(default=80) + value_align = param.ObjectSelector( + objects=["start", "center", "end"], doc=""" + Location to display the value of the slider + ("start", "center", "end")""") + width = param.Integer(default=510, allow_None=True, doc=""" Width of this component. If sizing_mode is set to stretch or scale mode this will merely be used as a suggestion.""") - _rename: ClassVar[Mapping[str, str | None]] = {'name': None} + scale_buttons = param.Number(default=1, doc=""" + The scaling factor to resize the buttons.""") + + visible_buttons = param.List(default=[ + 'slower', 'first', 'previous', 'reverse', 'pause', 'play', 'next', 'last', 'faster' + ], doc="""The buttons to display on the player.""") + + visible_loop_options = param.List(default=[ + 'once', 'loop', 'reflect' + ], doc="The loop options to display on the player.") + + _rename: ClassVar[Mapping[str, str | None]] = {'name': "title"} _widget_type: ClassVar[type[Model]] = _BkPlayer + _stylesheets: ClassVar[list[str]] = [f"{CDN_DIST}css/player.css"] + __abstract = True def __init__(self, **params): + if loop_options := params.get("visible_loop_options", []): + if params.get("loop_policy", "once") not in loop_options: + params["loop_policy"] = loop_options[0] if 'value' in params and 'value_throttled' in self.param: params['value_throttled'] = params['value'] super().__init__(**params) @@ -76,7 +107,7 @@ class Player(PlayerBase): :Example: - >>> Player(name='Player', start=0, end=100, value=32, loop_policy='loop') + >>> Player(name='Player', start=0, end=100, value=32, loop_policy='loop', value_align='top_center') """ start = param.Integer(default=0, doc="Lower bound on the slider value") @@ -130,7 +161,8 @@ class DiscretePlayer(PlayerBase, SelectBase): >>> DiscretePlayer( ... name='Discrete Player', ... options=[2, 4, 8, 16, 32, 64, 128], value=32, - ... loop_policy='loop' + ... loop_policy='loop', + ... value_align='start' ... ) """ @@ -140,10 +172,12 @@ class DiscretePlayer(PlayerBase, SelectBase): value_throttled = param.Parameter(constant=True, doc="Current player value") - _rename: ClassVar[Mapping[str, str | None]] = {'name': None, 'options': None} + _rename: ClassVar[Mapping[str, str | None]] = {'name': 'title'} _source_transforms: ClassVar[Mapping[str, str | None]] = {'value': None, 'value_throttled': None} + _widget_type: ClassVar[type[Model]] = _BkDiscretePlayer + def _process_param_change(self, msg): values = self.values if 'options' in msg: diff --git a/panel/widgets/tables.py b/panel/widgets/tables.py index dca0422c00..77fa7e5245 100644 --- a/panel/widgets/tables.py +++ b/panel/widgets/tables.py @@ -116,7 +116,10 @@ def __init__(self, value=None, **params): self._index_mapping = {} self._edited_indexes = [] super().__init__(value=value, **params) - self.param.watch(self._setup_on_change, ['editors', 'formatters']) + self._internal_callbacks.extend([ + self.param.watch(self._setup_on_change, ['editors', 'formatters']), + self.param._watch(self._reset_selection, ['value'], precedence=-1) + ]) self.param.trigger('editors') self.param.trigger('formatters') @@ -126,9 +129,35 @@ def _compute_renamed_cols(self): self._renamed_cols.clear() return self._renamed_cols = { - str(col) if str(col) != col else col: col for col in self._get_fields() + ('_'.join(col) if isinstance(col, tuple) else str(col)) if str(col) != col else col: col for col in self._get_fields() } + def _reset_selection(self, event): + if event.type == 'triggered' and self._updating: + return + if self._indexes_changed(event.old, event.new): + selection = [] + for sel in self.selection: + idx = event.old.index[sel] + try: + new = event.new.index.get_loc(idx) + selection.append(new) + except KeyError: + pass + self.selection = selection + + def _indexes_changed(self, old, new): + """ + Comparator that checks whether DataFrame indexes have changed. + + If indexes and length are unchanged we assume we do not + have to reset various settings including expanded rows, + scroll position, pagination etc. + """ + if type(old) != type(new) or isinstance(new, dict) or len(old) != len(new): + return True + return (old.index != new.index).any() + @property def _length(self): return len(self._processed) @@ -143,7 +172,7 @@ def _validate(self, *events: param.parameterized.Event): def _get_fields(self) -> list[str]: indexes = self.indexes - col_names = list(self.value.columns) + col_names = [] if self.value is None else list(self.value.columns) if not self.hierarchical or len(indexes) == 1: col_names = indexes + col_names else: @@ -245,12 +274,14 @@ def _get_column_definitions(self, col_names: list[str], df: pd.DataFrame) -> lis else: col_kwargs['width'] = 0 - title = self.titles.get(col, str(col)) + col_name = '_'.join(col) if isinstance(col, tuple) else col + title = self.titles.get(col, str(col_name)) if col in indexes and len(indexes) > 1 and self.hierarchical: title = 'Index: {}'.format(' | '.join(indexes)) elif col in self.indexes and col.startswith('level_'): title = '' - column = TableColumn(field=str(col), title=title, + + column = TableColumn(field=str(col_name), title=title, editor=editor, formatter=formatter, **col_kwargs) columns.append(column) @@ -295,24 +326,11 @@ def _update_index_mapping(self): @updating def _update_cds(self, *events: param.parameterized.Event): - old_processed = self._processed self._processed, data = self._get_data() self._update_index_mapping() - # If there is a selection we have to compute new index - if self.selection and old_processed is not None: - indexes = list(self._processed.index) - selection = [] - for sel in self.selection: - try: - iv = old_processed.index[sel] - idx = indexes.index(iv) - selection.append(idx) - except Exception: - continue - self.selection = selection self._data = {k: _convert_datetime_array_ignore_list(v) for k, v in data.items()} msg = {'data': self._data} - for ref, (m, _) in self._models.items(): + for ref, (m, _) in self._models.copy().items(): self._apply_update(events, msg, m.source, ref) def _process_param_change(self, params): @@ -369,6 +387,9 @@ def _sort_df(self, df: pd.DataFrame) -> pd.DataFrame: fields = [self._renamed_cols.get(s['field'], s['field']) for s in self.sorters] ascending = [s['dir'] == 'asc' for s in self.sorters] + # Making a copy of the DataFrame because it could be a view of the original + # dataframe. There could be a better place to do this. + df = df.copy() # Temporarily add _index_ column because Tabulator uses internal _index # as additional sorter to break ties df['_index_'] = np.arange(len(df)).astype(str) @@ -398,9 +419,7 @@ def tabulator_sorter(col): # Revert temporary changes to DataFrames if rename: - df.index.name = None df_sorted.index.name = None - df.drop(columns=['_index_'], inplace=True) df_sorted.drop(columns=['_index_'], inplace=True) return df_sorted @@ -712,7 +731,12 @@ def stream(self, stream_value, rollover=None, reset_index=True): if reset_index: stream_value = stream_value.reset_index(drop=True) stream_value.index += value_index_start - combined = pd.concat([self.value, stream_value]) + if self.value.empty: + combined = pd.DataFrame( + stream_value, columns=self.value.columns + ).astype(self.value.dtypes) + else: + combined = pd.concat([self.value, stream_value]) if rollover is not None: combined = combined.iloc[-rollover:] with param.discard_events(self): @@ -873,7 +897,8 @@ def selected_dataframe(self): """ if not self.selection: return self.current_view.iloc[:0] - return self.current_view.iloc[self.selection] + df = self.value.iloc[self.selection] + return self._filter_dataframe(df) class DataFrame(BaseTable): @@ -1022,6 +1047,29 @@ def _update_aggregators(self, model): g.aggregators = self._get_aggregators(index) +class _ListValidateWithCallable(param.List): + + __slots__ = ['callable'] + + def __init__(self, **params): + self.callable = params.pop("callable", None) + super().__init__(**params) + + def _validate(self, val): + super()._validate(val) + self._validate_callable(val) + + def _validate_callable(self, val): + if self.callable is not None: + selectable = self.callable() + if selectable and val: + if set(val) - set(selectable): + raise ValueError( + "Values in 'selection' must not have values " + "which are not available with 'selectable_rows'." + ) + + class Tabulator(BaseTable): """ The `Tabulator` widget wraps the [Tabulator js](http://tabulator.info/) @@ -1084,13 +1132,16 @@ class Tabulator(BaseTable): 'fit_data', 'fit_data_fill', 'fit_data_stretch', 'fit_data_table', 'fit_columns']) + initial_page_size = param.Integer(default=20, bounds=(1, None), doc=""" + Initial page size if page_size is None and therefore automatically set.""") + pagination = param.ObjectSelector(default=None, allow_None=True, objects=['local', 'remote']) page = param.Integer(default=1, doc=""" Currently selected page (indexed starting at 1), if pagination is enabled.""") - page_size = param.Integer(default=20, bounds=(1, None), doc=""" + page_size = param.Integer(default=None, bounds=(1, None), doc=""" Number of rows to render per page, if pagination is enabled.""") row_content = param.Callable(doc=""" @@ -1100,6 +1151,10 @@ class Tabulator(BaseTable): row_height = param.Integer(default=30, doc=""" The height of each table row.""") + selection = _ListValidateWithCallable(default=[], doc=""" + The currently selected rows of the table. It validates + its values against 'selectable_rows' if used.""") + selectable = param.ClassSelector( default=True, class_=(bool, str, int), doc=""" Defines the selection mode of the Tabulator. @@ -1156,13 +1211,13 @@ class Tabulator(BaseTable): _manual_params: ClassVar[list[str]] = BaseTable._manual_params + _config_params - _priority_changes: ClassVar[list[str]] = ['data'] + _priority_changes: ClassVar[list[str]] = ['data', 'filters'] _rename: ClassVar[Mapping[str, str | None]] = { 'selection': None, 'row_content': None, 'row_height': None, 'text_align': None, 'embed_content': None, 'header_align': None, 'header_filters': None, 'styles': 'cell_styles', - 'title_formatters': None, 'sortable': None + 'title_formatters': None, 'sortable': None, 'initial_page_size': None } # Determines the maximum size limits beyond which (local, remote) @@ -1184,6 +1239,7 @@ def __init__(self, value=None, **params): self.style = None self._computed_styler = None self._child_panels = {} + self._indexed_children = {} self._explicit_pagination = 'pagination' in params self._on_edit_callbacks = [] self._on_click_callbacks = {} @@ -1198,6 +1254,7 @@ def __init__(self, value=None, **params): self.on_edit(edit_handler) if style is not None: self.style._todo = style._todo + self.param.selection.callable = self._get_selectable @param.depends('value', watch=True, on_init=True) def _apply_max_size(self): @@ -1248,14 +1305,22 @@ def _cleanup(self, root: Model | None = None) -> None: p._cleanup(root) super()._cleanup(root) + def _process_events(self, events: dict[str, Any]) -> None: + if 'expanded' in events: + self._update_expanded(events.pop('expanded')) + if events.get('page_size') == 0: # page_size can't be 0 + events.pop('page_size') + return super()._process_events(events) + def _process_event(self, event) -> None: if event.event_name == 'selection-change': - self._update_selection(event) + if self.pagination == 'remote': + self._update_selection(event) return event_col = self._renamed_cols.get(event.column, event.column) if self.pagination == 'remote': - nrows = self.page_size + nrows = self.page_size or self.initial_page_size event.row = event.row+(self.page-1)*nrows idx = self._index_mapping.get(event.row, event.row) @@ -1343,7 +1408,7 @@ def _get_data(self): import pandas as pd df = self._filter_dataframe(self.value) df = self._sort_df(df) - nrows = self.page_size + nrows = self.page_size or self.initial_page_size start = (self.page-1)*nrows page_df = df.iloc[start: start+nrows] @@ -1381,10 +1446,14 @@ def _get_style_data(self, recompute=True): styler = self._computed_styler if styler is None: return {} - offset = 1 + len(self.indexes) + int(self.selectable in ('checkbox', 'checkbox-single')) + int(bool(self.row_content)) + + # Compute offsets (not that multi-indexes are reset so don't require an offset) + offset = 1 + int(len(self.indexes) == 1) + int(self.selectable in ('checkbox', 'checkbox-single')) + int(bool(self.row_content)) + if self.pagination == 'remote': - start = (self.page-1)*self.page_size - end = start + self.page_size + page_size = self.page_size or self.initial_page_size + start = (self.page - 1) * page_size + end = start + page_size # Map column indexes in the data to indexes after frozen_columns are applied column_mapper = {} @@ -1428,7 +1497,7 @@ def _get_selectable(self): return None df = self._processed if self.pagination == 'remote': - nrows = self.page_size + nrows = self.page_size or self.initial_page_size start = (self.page-1)*nrows df = df.iloc[start:(start+nrows)] return self.selectable_rows(df) @@ -1436,30 +1505,53 @@ def _get_selectable(self): def _update_style(self, recompute=True): styles = self._get_style_data(recompute) msg = {'cell_styles': styles} - for ref, (m, _) in self._models.items(): + for ref, (m, _) in self._models.copy().items(): self._apply_update([], msg, m, ref) - def _get_children(self, old={}): + def _get_children(self): if self.row_content is None or self.value is None: - return {} + return {}, [], [] from ..pane import panel df = self._processed if self.pagination == 'remote': - nrows = self.page_size + nrows = self.page_size or self.initial_page_size start = (self.page-1)*nrows df = df.iloc[start:(start+nrows)] - children = {} - for i in (range(len(df)) if self.embed_content else self.expanded): - if i in old: - children[i] = old[i] - else: - children[i] = panel(self.row_content(df.iloc[i])) - return children - - def _get_model_children(self, panels, doc, root, parent, comm=None): + indexed_children, children = {}, {} + expanded = [] + if self.embed_content: + for i in range(len(df)): + expanded.append(i) + idx = df.index[i] + if idx in self._indexed_children: + child = self._indexed_children[idx] + else: + child = panel(self.row_content(df.iloc[i])) + indexed_children[idx] = children[i] = child + else: + for i in self.expanded: + idx = self.value.index[i] + if idx in self._indexed_children: + child = self._indexed_children[idx] + else: + child = panel(self.row_content(self.value.iloc[i])) + try: + loc = df.index.get_loc(idx) + except KeyError: + continue + expanded.append(loc) + indexed_children[idx] = children[loc] = child + removed = [ + child for idx, child in self._indexed_children.items() + if idx not in indexed_children + ] + self._indexed_children = indexed_children + return children, removed, expanded + + def _get_model_children(self, doc, root, parent, comm=None): ref = root.ref['id'] models = {} - for i, p in panels.items(): + for i, p in self._child_panels.items(): if ref in p._models: model = p._models[ref][0] else: @@ -1468,75 +1560,50 @@ def _get_model_children(self, panels, doc, root, parent, comm=None): models[i] = model return models - def _indexes_changed(self, old, new): - """ - Comparator that checks whether DataFrame indexes have changed. - - If indexes and length are unchanged we assume we do not - have to reset various settings including expanded rows, - scroll position, pagination etc. - """ - if type(old) != type(new) or isinstance(new, dict): - return True - elif len(old) != len(new): - return False - return (old.index != new.index).any() - def _update_children(self, *events): - cleanup, reuse = set(), set() - page_events = ('page', 'page_size', 'pagination') - old_panels = self._child_panels + if all(e.name in ('page', 'page_size', 'pagination', 'sorters') for e in events) and self.pagination != 'remote': + return for event in events: - if event.name == 'expanded' and len(events) == 1: - if self.embed_content: - cleanup = set() - reuse = set(old_panels) - else: - cleanup = set(event.old) - set(event.new) - reuse = set(event.old) & set(event.new) - elif ( - (event.name == 'value' and self._indexes_changed(event.old, event.new)) or - (event.name in page_events and not self._updating) or - (self.pagination == 'remote' and event.name == 'sorters') - ): + if event.name == 'value' and self._indexes_changed(event.old, event.new): self.expanded = [] + self._indexed_children.clear() return - self._child_panels = child_panels = self._get_children( - {i: old_panels[i] for i in reuse} - ) - for ref, (m, _) in self._models.items(): + elif event.name == 'row_content': + self._indexed_children.clear() + self._child_panels, removed, expanded = self._get_children() + for ref, (m, _) in self._models.copy().items(): root, doc, comm = state._views[ref][1:] - for idx in cleanup: - old_panels[idx]._cleanup(root) - children = self._get_model_children( - child_panels, doc, root, m, comm - ) - msg = {'children': children} + for child_panel in removed: + child_panel._cleanup(root) + children = self._get_model_children(doc, root, m, comm) + msg = {'expanded': expanded, 'children': children} self._apply_update([], msg, m, ref) @updating def _stream(self, stream, rollover=None, follow=True): if self.pagination == 'remote': length = self._length - nrows = self.page_size + nrows = self.page_size or self.initial_page_size max_page = max(length//nrows + bool(length%nrows), 1) - if self.page != max_page: + if self.page != max_page and not follow: return + self._processed, _ = self._get_data() + return super()._stream(stream, rollover) self._update_style() self._update_selectable() self._update_index_mapping() def stream(self, stream_value, rollover=None, reset_index=True, follow=True): - for ref, (model, _) in self._models.items(): + for ref, (model, _) in self._models.copy().items(): self._apply_update([], {'follow': follow}, model, ref) - if follow and self.pagination: - length = self._length - nrows = self.page_size - self.page = max(length//nrows + bool(length%nrows), 1) super().stream(stream_value, rollover, reset_index) if follow and self.pagination: self._update_max_page() + if follow and self.pagination: + length = self._length + nrows = self.page_size or self.initial_page_size + self.page = max(length//nrows + bool(length%nrows), 1) @updating def _patch(self, patch): @@ -1545,8 +1612,8 @@ def _patch(self, patch): self._update_cds() return if self.pagination == 'remote': - nrows = self.page_size - start = (self.page-1)*nrows + nrows = self.page_size or self.initial_page_size + start = (self.page - 1) * nrows end = start+nrows filtered = {} for c, values in patch.items(): @@ -1564,10 +1631,10 @@ def _patch(self, patch): def _update_cds(self, *events): if any(event.name == 'filters' for event in events): self._edited_indexes = [] - page_events = ('page', 'page_size', 'sorters', 'filters') + page_events = ('page', 'page_size', 'sorters') if self._updating: return - elif events and all(e.name in page_events[:-1] for e in events) and self.pagination == 'local': + elif events and all(e.name in page_events for e in events) and self.pagination == 'local': return elif events and all(e.name in page_events for e in events) and not self.pagination: self._processed, _ = self._get_data() @@ -1586,15 +1653,16 @@ def _update_cds(self, *events): def _update_selectable(self): selectable = self._get_selectable() - for ref, (model, _) in self._models.items(): + for ref, (model, _) in self._models.copy().items(): self._apply_update([], {'selectable_rows': selectable}, model, ref) + @param.depends('page_size', watch=True) def _update_max_page(self): length = self._length - nrows = self.page_size + nrows = self.page_size or self.initial_page_size max_page = max(length//nrows + bool(length%nrows), 1) self.param.page.bounds = (1, max_page) - for ref, (model, _) in self._models.items(): + for ref, (model, _) in self._models.copy().items(): self._apply_update([], {'max_page': max_page}, model, ref) def _clear_selection_remote_pagination(self, event): @@ -1615,8 +1683,8 @@ def _update_selected(self, *events: param.parameterized.Event, indices=None): indices.append((ind, iloc)) except KeyError: continue - nrows = self.page_size - start = (self.page-1)*nrows + nrows = self.page_size or self.initial_page_size + start = (self.page - 1) * nrows end = start+nrows p_range = self._processed.index[start:end] kwargs['indices'] = [iloc - start for ind, iloc in indices @@ -1632,8 +1700,8 @@ def _update_column(self, column: str, array: np.ndarray): with pd.option_context('mode.chained_assignment', None): self._processed[column] = array return - nrows = self.page_size - start = (self.page-1)*nrows + nrows = self.page_size or self.initial_page_size + start = (self.page - 1) * nrows end = start+nrows index = self._processed.iloc[start:end].index.values self.value.loc[index, column] = array @@ -1641,32 +1709,39 @@ def _update_column(self, column: str, array: np.ndarray): with pd.option_context('mode.chained_assignment', None): self._processed.loc[index, column] = array - def _update_selection(self, indices: list[int] | SelectionEvent): - if self.pagination != 'remote': - self.selection = indices - return - if isinstance(indices, list): - selected = True - ilocs = [] - else: # SelectionEvent - selected = indices.selected - ilocs = [] if indices.flush else self.selection.copy() - indices = indices.indices - - nrows = self.page_size - start = (self.page-1)*nrows - index = self._processed.iloc[[start+ind for ind in indices]].index + def _map_indexes(self, indexes, existing=[], add=True): + if self.pagination == 'remote': + nrows = self.page_size or self.initial_page_size + start = (self.page-1)*nrows + else: + start = 0 + ilocs = list(existing) + index = self._processed.iloc[[start+ind for ind in indexes]].index for v in index.values: try: iloc = self.value.index.get_loc(v) self._validate_iloc(v, iloc) except KeyError: continue - if selected: + if add: ilocs.append(iloc) elif iloc in ilocs: ilocs.remove(iloc) - ilocs = list(dict.fromkeys(ilocs)) + return list(dict.fromkeys(ilocs)) + + def _update_expanded(self, expanded): + self.expanded = self._map_indexes(expanded) + + def _update_selection(self, indices: list[int] | SelectionEvent): + if isinstance(indices, list): + selected = True + ilocs = [] + else: + selected = indices.selected + ilocs = [] if indices.flush else self.selection.copy() + indices = indices.indices + + ilocs = self._map_indexes(indices, ilocs, add=selected) if isinstance(self.selectable, int) and not isinstance(self.selectable, bool): ilocs = ilocs[len(ilocs) - self.selectable:] self.selection = ilocs @@ -1678,7 +1753,8 @@ def _get_properties(self, doc: Document) -> dict[str, Any]: properties['indexes'] = self.indexes if self.pagination: length = self._length - properties['max_page'] = max(length//self.page_size + bool(length%self.page_size), 1) + page_size = self.page_size or self.initial_page_size + properties['max_page'] = max(length//page_size + bool(length % page_size), 1) if isinstance(self.selectable, str) and self.selectable.startswith('checkbox'): properties['select_mode'] = 'checkbox' else: @@ -1716,11 +1792,10 @@ def _get_model( ) model = super()._get_model(doc, root, parent, comm) root = root or model - self._child_panels = child_panels = self._get_children() - model.children = self._get_model_children( - child_panels, doc, root, parent, comm - ) - self._link_props(model, ['page', 'sorters', 'expanded', 'filters'], doc, root, comm) + self._child_panels, removed, expanded = self._get_children() + model.expanded = expanded + model.children = self._get_model_children(doc, root, parent, comm) + self._link_props(model, ['page', 'sorters', 'expanded', 'filters', 'page_size'], doc, root, comm) self._register_events('cell-click', 'table-edit', 'selection-change', model=model, doc=doc, comm=comm) return model @@ -1778,8 +1853,11 @@ def _get_filter_spec(self, column: TableColumn) -> dict[str, Any]: ) del filter_params['values'] filter_params['valuesLookup'] = True - if filter_type == 'list' and not filter_params: - filter_params = {'valuesLookup': True} + if filter_type == 'list': + if not filter_params: + filter_params = {'valuesLookup': True} + if filter_func is None: + filter_func = 'in' fspec['headerFilter'] = filter_type if filter_params: fspec['headerFilterParams'] = filter_params @@ -1834,6 +1912,7 @@ def _config_columns(self, column_objs: list[TableColumn]) -> list[dict[str, Any] } for i, column in enumerate(ordered_columns): field = column.field + index = self._renamed_cols[field] matching_groups = [ group for group, group_cols in grouping.items() if field in group_cols @@ -1865,14 +1944,13 @@ def _config_columns(self, column_objs: list[TableColumn]) -> list[dict[str, Any] title_formatter = dict(title_formatter) col_dict['titleFormatter'] = title_formatter.pop('type') col_dict['titleFormatterParams'] = title_formatter - col_name = self._renamed_cols[field] if field in self.indexes: if len(self.indexes) == 1: dtype = self.value.index.dtype else: dtype = self.value.index.get_level_values(self.indexes.index(field)).dtype else: - dtype = self.value.dtypes[col_name] + dtype = self.value.dtypes[index] if dtype.kind == 'M': col_dict['sorter'] = 'timestamp' elif dtype.kind in 'iuf': @@ -1902,7 +1980,27 @@ def _config_columns(self, column_objs: list[TableColumn]) -> list[dict[str, Any] if isinstance(self.widths, dict) and isinstance(self.widths.get(field), str): col_dict['width'] = self.widths[field] col_dict.update(self._get_filter_spec(column)) - if matching_groups: + + if isinstance(index, tuple): + if columns: + children = columns + last = children[-1] + for group in index[:-1]: + if 'title' in last and last['title'] == group: + new = False + children = last['columns'] + else: + new = True + children.append({ + 'columns': [], + 'title': group, + }) + last = children[-1] + if new: + children = last['columns'] + children.append(col_dict) + column.title = index[-1] + elif matching_groups: group = matching_groups[0] if group in groups: groups[group]['columns'].append(col_dict) @@ -1944,7 +2042,7 @@ def download(self, filename: str = 'table.csv'): filename: str The filename to save the table as. """ - for ref, (model, _) in self._models.items(): + for ref, (model, _) in self._models.copy().items(): self._apply_update({}, {'filename': filename}, model, ref) self._apply_update({}, {'download': not model.download}, model, ref) diff --git a/pixi.toml b/pixi.toml index 005371371d..a216fa1a3e 100644 --- a/pixi.toml +++ b/pixi.toml @@ -16,23 +16,27 @@ test-311 = ["py311", "test-core", "test", "example", "test-example", "test-unit- test-312 = ["py312", "test-core", "test", "example", "test-example", "test-unit-task"] test-ui = ["py312", "test-core", "test", "test-ui"] test-core = ["py312", "test-core", "test-unit-task"] +test-type = ["py311", "type"] docs = ["py311", "example", "doc"] build = ["py311", "build"] lint = ["py311", "lint"] +lite = ["py311", "lite"] [dependencies] +nodejs = ">=18" +nomkl = "*" +pip = "*" +# Required bleach = "*" bokeh = ">=3.5.0,<3.6.0" linkify-it-py = "*" markdown = "*" markdown-it-py = "*" mdit-py-plugins = "*" -nodejs = ">=18" -numpy = "<2.0" # Temporary pin until panel release with support for bokeh 3.5.0 is available +numpy = "*" packaging = "*" pandas = ">=1.2" param = ">=2.1.0,<3.0" -pip = "*" pyviz_comms = ">=2.0.0" requests = "*" tqdm = ">=4.48.0" @@ -144,6 +148,23 @@ _install-ui = 'playwright install chromium' cmd = 'pytest panel/tests/ui --ui --browser chromium -n logical --dist loadgroup --reruns 3 --reruns-delay 10' depends_on = ["_install-ui"] +# ============================================= +# ================== TYPES ==================== +# ============================================= +[feature.type.dependencies] +mypy = "*" +pandas-stubs = "*" +types-bleach = "*" +types-croniter = "*" +types-Markdown = "*" +types-psutil = "*" +types-requests = "*" +types-tqdm = "*" +typing-extensions = "*" + +[feature.type.tasks] +test-type = 'mypy panel' + # ============================================= # =================== DOCS ==================== # ============================================= @@ -195,3 +216,18 @@ pre-commit = "*" [feature.lint.tasks] lint = 'pre-commit run --all-files' lint-install = 'pre-commit install' + +# ============================================= +# =================== LITE ==================== +# ============================================= +[feature.lite.dependencies] +jupyterlab-myst = "*" +jupyterlite-core = "*" +jupyterlite-pyodide-kernel = "*" +python-build = "*" + +[feature.lite.tasks] +lite-build = "bash scripts/jupyterlite/build.sh" +# Service worker only work on 127.0.0.1 +# https://jupyterlite.readthedocs.io/en/latest/howto/configure/advanced/service-worker.html#limitations +lite-server = "python -m http.server --directory ./lite/dist --bind 127.0.0.1" diff --git a/pyproject.toml b/pyproject.toml index b2f3f050cd..86444af24c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,17 @@ tests = [ 'pytest-rerunfailures', 'pytest-xdist', ] +mypy = [ + "mypy", + "pandas-stubs", + "types-bleach", + "types-croniter", + "types-Markdown", + "types-psutil", + "types-requests", + "types-tqdm", + "typing-extensions", +] [project.scripts] panel = "panel.command:main" @@ -105,6 +116,7 @@ include = ["panel"] [tool.hatch.build.targets.sdist] include = ["panel", "scripts", "examples"] +exclude = ["scripts/jupyterlite"] [tool.hatch.build.targets.sdist.force-include] "panel/dist" = "panel/dist" @@ -197,7 +209,66 @@ xfail_strict = true minversion = "7" log_cli_level = "INFO" filterwarnings = [ + "error", # 2023-11: `pkg_resources` is deprecated "ignore:Deprecated call to `pkg_resources.+?'zope:DeprecationWarning", # https://github.com/zopefoundation/meta/issues/194 "ignore: pkg_resources is deprecated as an API:DeprecationWarning:streamz.plugins", # https://github.com/python-streamz/streamz/issues/460 + # 2024-06: Adding error to the filterwarnings + "ignore:Jupyter is migrating its paths to use standard platformdirs:DeprecationWarning", # OK + "ignore:distutils Version classes are deprecated:DeprecationWarning:ipywidgets_bokeh.kernel", # OK + "ignore:unclosed file <_io.TextIOWrapper name='(/dev/null|nul)' mode='w':ResourceWarning", # OK + "ignore:Deprecated in traitlets 4.1, use the instance .metadata dictionary directly", # OK (ipywidgets internal) +] + +[tool.mypy] +namespace_packages = true +explicit_package_bases = true +mypy_path = "" +exclude = [] + +[[tool.mypy.overrides]] +module = [ + "altair.*", + "bokeh_django.*", + "bokeh.*", + "cachecontrol.*", + "cryptography.*", + "diskcache.*", + "flask.*", + "fsspec.*", + "holoviews.*", + "ipympl.*", + "ipywidgets_bokeh", + "ipywidgets.*", + "js.*", + "jupyter_bokeh.*", + "jupyter_server.*", + "langchain.*", + "lumen.*", + "magic.*", + "matplotlib.*", + "mdit_py_emoji.*", + "memray.*", + "myst_parser.*", + "nbconvert.*", + "nbformat.*", + "param.*", + "playwright.*", + "plotly.*", + "pydeck.*", + "pyecharts.*", + "pyinstrument.*", + "pyodide_http.*", + "pyodide.*", + "pyviz_comms.*", + "reacton.*", + "rpy2.*", + "scipy.*", + "setuptools_scm.*", + "snakeviz.*", + "streamz.*", + "textual.*", + "tranquilizer.*", + "vtk.*", ] +ignore_missing_imports = true diff --git a/scripts/conda/recipe/meta.yaml b/scripts/conda/recipe/meta.yaml index a15d37e9b1..05098aacab 100644 --- a/scripts/conda/recipe/meta.yaml +++ b/scripts/conda/recipe/meta.yaml @@ -37,6 +37,7 @@ test: requires: - pip - pytest-asyncio + - pytest-rerunfailures - pytest-xdist commands: - pip check diff --git a/scripts/jupyterlite/build.sh b/scripts/jupyterlite/build.sh new file mode 100644 index 0000000000..35d30bf114 --- /dev/null +++ b/scripts/jupyterlite/build.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -euxo pipefail + +python ./scripts/build_pyodide_wheels.py dist +python ./scripts/panelite/generate_panelite_content.py + +# Update lockfiles +cd "$(dirname "${BASH_SOURCE[0]}")" +rm -rf node_modules +npm install . +node update_lock.js +python patch_lock.py +rm node_modules/pyodide/*.whl + +jupyter lite build + +cp -r node_modules/pyodide/ ../../lite/dist/pyodide +mv pyodide-lock.json ../../lite/dist/pyodide/pyodide-lock.json +mv ../../dist/* ../../lite/dist/pyodide diff --git a/scripts/jupyterlite/extra_packages.json b/scripts/jupyterlite/extra_packages.json new file mode 100644 index 0000000000..3d15b58add --- /dev/null +++ b/scripts/jupyterlite/extra_packages.json @@ -0,0 +1,5 @@ +[ + "panel", + "bokeh", + "pyodide-http" +] diff --git a/lite/files/Getting_Started.ipynb b/scripts/jupyterlite/files/Getting_Started.ipynb similarity index 94% rename from lite/files/Getting_Started.ipynb rename to scripts/jupyterlite/files/Getting_Started.ipynb index 04e11635e5..92a27bb37f 100644 --- a/lite/files/Getting_Started.ipynb +++ b/scripts/jupyterlite/files/Getting_Started.ipynb @@ -5,7 +5,7 @@ "id": "8c91012d-3445-4052-ab2f-129ca785a666", "metadata": {}, "source": [ - "Panel is not installed by default in the Pyodide distribution that JupyterLite is built on, therefore we must install it manually:" + "Panel is installed by default in the Pyodide distribution that JupyterLite is built on. Though, not all dependencies are therefore we must install it manually:" ] }, { @@ -16,7 +16,7 @@ "outputs": [], "source": [ "import piplite\n", - "await piplite.install(['panel', 'pyodide-http', 'altair'])" + "await piplite.install(['altair'])" ] }, { diff --git a/lite/files/Reset_Jupyterlite.ipynb b/scripts/jupyterlite/files/Reset_Jupyterlite.ipynb similarity index 99% rename from lite/files/Reset_Jupyterlite.ipynb rename to scripts/jupyterlite/files/Reset_Jupyterlite.ipynb index 7f70f88361..f88985ff64 100644 --- a/lite/files/Reset_Jupyterlite.ipynb +++ b/scripts/jupyterlite/files/Reset_Jupyterlite.ipynb @@ -57,5 +57,5 @@ } }, "nbformat": 4, - "nbformat_minor": 2 + "nbformat_minor": 4 } diff --git a/scripts/jupyterlite/jupyter-lite.json b/scripts/jupyterlite/jupyter-lite.json new file mode 100644 index 0000000000..b8f4613bf6 --- /dev/null +++ b/scripts/jupyterlite/jupyter-lite.json @@ -0,0 +1,10 @@ +{ + "jupyter-lite-schema-version": 0, + "jupyter-config-data": { + "litePluginSettings": { + "@jupyterlite/pyodide-kernel-extension:kernel": { + "pyodideUrl": "./pyodide/pyodide.js" + } + } + } + } diff --git a/scripts/jupyterlite/jupyter_lite_config.json b/scripts/jupyterlite/jupyter_lite_config.json new file mode 100644 index 0000000000..4385c1d261 --- /dev/null +++ b/scripts/jupyterlite/jupyter_lite_config.json @@ -0,0 +1,6 @@ +{ + "LiteBuildConfig": { + "contents": ["../../lite/files", "files"], + "output_dir": "../../lite/dist" + } +} diff --git a/scripts/jupyterlite/package-lock.json b/scripts/jupyterlite/package-lock.json new file mode 100644 index 0000000000..4bb78f3385 --- /dev/null +++ b/scripts/jupyterlite/package-lock.json @@ -0,0 +1,44 @@ +{ + "name": "jupyterlite", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "pyodide": "^0.26.2" + } + }, + "node_modules/pyodide": { + "version": "0.26.2", + "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.26.2.tgz", + "integrity": "sha512-8VCRdFX83gBsWs6XP2rhG8HMaB+JaVyyav4q/EMzoV8fXH8HN6T5IISC92SNma6i1DRA3SVXA61S1rJcB8efgA==", + "license": "Apache-2.0", + "dependencies": { + "ws": "^8.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/scripts/jupyterlite/package.json b/scripts/jupyterlite/package.json new file mode 100644 index 0000000000..b2fcd7fe76 --- /dev/null +++ b/scripts/jupyterlite/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "pyodide": "^0.26.2" + } +} diff --git a/scripts/jupyterlite/patch_lock.py b/scripts/jupyterlite/patch_lock.py new file mode 100644 index 0000000000..cb083c2873 --- /dev/null +++ b/scripts/jupyterlite/patch_lock.py @@ -0,0 +1,47 @@ +import hashlib +import json +import os.path + +from glob import glob + +from packaging.utils import parse_wheel_filename + + +def calculate_sha256(file_path): + sha256_hash = hashlib.sha256() + + with open(file_path, "rb") as file: + for byte_block in iter(lambda: file.read(4096), b""): + sha256_hash.update(byte_block) + + return sha256_hash.hexdigest() + + +with open("package.json") as f: + package = json.load(f) +pyodide_version = package["dependencies"]["pyodide"].removeprefix("^") + +path = "pyodide-lock.json" +url = f"https://cdn.jsdelivr.net/pyodide/v{pyodide_version}/full" + +with open(path) as f: + data = json.load(f) + +for p in data["packages"].values(): + if not p["file_name"].startswith("http"): + p["file_name"] = f'{url}/{p["file_name"]}' + + +whl_files = glob("../../dist/*.whl") +for whl_file in whl_files: + name, version, *_ = parse_wheel_filename(os.path.basename(whl_file)) + + package = data["packages"][name] + package["version"] = str(version) + package["file_name"] = os.path.basename(whl_file) + package["sha256"] = calculate_sha256(whl_file) + package["imports"] = [name] + + +with open(path, "w") as f: + data = json.dump(data, f) diff --git a/scripts/jupyterlite/update_lock.js b/scripts/jupyterlite/update_lock.js new file mode 100644 index 0000000000..c46e1fa0df --- /dev/null +++ b/scripts/jupyterlite/update_lock.js @@ -0,0 +1,20 @@ +const { loadPyodide } = require("pyodide"); +const fs = require("fs"); + +async function main() { + const extra = fs.readFileSync("extra_packages.json", "utf8"); + + let pyodide = await loadPyodide(); + await pyodide.loadPackage(["micropip"]); + + output = await pyodide.runPythonAsync(` +import json +import micropip +extra = json.loads("""${extra}""") +await micropip.install(extra) +micropip.freeze() +`); + fs.writeFileSync("pyodide-lock.json", output); +} + +main(); diff --git a/scripts/panelite/generate_panelite_content.py b/scripts/panelite/generate_panelite_content.py index d23f6532c4..a6251e8cc4 100644 --- a/scripts/panelite/generate_panelite_content.py +++ b/scripts/panelite/generate_panelite_content.py @@ -1,7 +1,6 @@ """ Helper script to convert and copy example notebooks into JupyterLite build. """ -import hashlib import json import os import pathlib @@ -17,7 +16,7 @@ EXAMPLES_DIR = PANEL_BASE / 'examples' LITE_FILES = PANEL_BASE / 'lite' / 'files' DOC_DIR = PANEL_BASE / 'doc' -BASE_DEPENDENCIES = ['panel', 'pyodide-http'] +BASE_DEPENDENCIES = [] MINIMUM_VERSIONS = {} INLINE_DIRECTIVE = re.compile('\{.*\}`.*`\s*') diff --git a/scripts/panelite/test/test_panelite.py b/scripts/panelite/test/test_panelite.py index a79cd6cc32..e969737963 100644 --- a/scripts/panelite/test/test_panelite.py +++ b/scripts/panelite/test/test_panelite.py @@ -57,7 +57,7 @@ def get_panelite_nb_paths(): nbs = list(FILES.glob('*/*/*.ipynb')) + list(FILES.glob('*/*.*')) for nb in nbs: path = str(nb).replace("\\", "/").split("files/")[-1] - if path.endswith(".ipynb") and not ".ipynb_checkpoints" in path: + if path.endswith(".ipynb") and ".ipynb_checkpoints" not in path: yield path PATHS_WITH_NOTHING_TO_TEST = [ "gallery/demos/attractors.ipynb", @@ -66,7 +66,7 @@ def get_panelite_nb_paths(): "gallery/demos/nyc_taxi.ipynb", "gallery/demos/portfolio-optimizer.ipynb", ] -PATHS = list(path for path in get_panelite_nb_paths() if not path in PATHS_WITH_NOTHING_TO_TEST) +PATHS = list(path for path in get_panelite_nb_paths() if path not in PATHS_WITH_NOTHING_TO_TEST) PATHS_WITHOUT_ISSUES = list(path for path in PATHS if path not in NOTEBOOK_ISSUES) PATHS_WITH_ISSUES = list(path for path in PATHS if path in NOTEBOOK_ISSUES)