浏览代码

Merge branch 'main' into leaflet

# Conflicts:
#	DEPENDENCIES.md
#	nicegui/ui.py
Falko Schindler 1 年之前
父节点
当前提交
306631f20c
共有 100 个文件被更改,包括 1991 次插入409 次删除
  1. 35 0
      .devcontainer/Dockerfile
  2. 39 0
      .devcontainer/devcontainer.json
  3. 5 0
      .dockerignore
  4. 4 0
      .github/ISSUE_TEMPLATE/issue.yml
  5. 1 1
      .github/workflows/test.yml
  6. 7 6
      .github/workflows/update_citation.py
  7. 2 1
      .gitignore
  8. 21 4
      .vscode/settings.json
  9. 3 3
      CITATION.cff
  10. 15 4
      CONTRIBUTING.md
  11. 15 13
      DEPENDENCIES.md
  12. 1 0
      README.md
  13. 1 1
      deploy.sh
  14. 40 0
      docker-entrypoint.sh
  15. 12 12
      docker.sh
  16. 4 12
      examples/ai_interface/main.py
  17. 32 5
      examples/authentication/main.py
  18. 1 1
      examples/chat_with_ai/main.py
  19. 40 0
      examples/custom_binding/main.py
  20. 10 0
      examples/descope_auth/README.md
  21. 26 0
      examples/descope_auth/main.py
  22. 1 0
      examples/descope_auth/requirements.txt
  23. 105 0
      examples/descope_auth/user.py
  24. 37 0
      examples/docker_image/README.md
  25. 17 0
      examples/docker_image/app/main.py
  26. 13 0
      examples/docker_image/docker-compose.yml
  27. 27 0
      examples/download_text_as_file/main.py
  28. 1 0
      examples/generate_pdf/.gitignore
  29. 51 0
      examples/generate_pdf/main.py
  30. 1 1
      examples/generate_pdf/requirements.txt
  31. 3 10
      examples/opencv_webcam/main.py
  32. 3 12
      examples/progress/main.py
  33. 3 1
      examples/script_executor/main.py
  34. 2 0
      examples/slideshow/main.py
  35. 2 2
      fetch_google_fonts.py
  36. 54 0
      fetch_milestone.py
  37. 31 25
      fetch_tailwind.py
  38. 12 0
      fly-entrypoint.sh
  39. 16 1
      fly.dockerfile
  40. 28 28
      fly.toml
  41. 55 14
      main.py
  42. 7 4
      nicegui.code-workspace
  43. 6 5
      nicegui/__init__.py
  44. 40 27
      nicegui/air.py
  45. 1 1
      nicegui/api_router.py
  46. 35 18
      nicegui/app.py
  47. 8 7
      nicegui/background_tasks.py
  48. 44 30
      nicegui/binding.py
  49. 39 11
      nicegui/client.py
  50. 3 0
      nicegui/dataclasses.py
  51. 20 19
      nicegui/dependencies.py
  52. 0 13
      nicegui/deprecation.py
  53. 105 25
      nicegui/element.py
  54. 0 0
      nicegui/elements/__init__.py
  55. 8 1
      nicegui/elements/aggrid.js
  56. 53 5
      nicegui/elements/aggrid.py
  57. 9 0
      nicegui/elements/audio.js
  58. 20 3
      nicegui/elements/audio.py
  59. 12 2
      nicegui/elements/card.py
  60. 5 3
      nicegui/elements/carousel.py
  61. 20 0
      nicegui/elements/chart.js
  62. 62 2
      nicegui/elements/chart.py
  63. 2 0
      nicegui/elements/checkbox.py
  64. 11 0
      nicegui/elements/choice_element.py
  65. 35 0
      nicegui/elements/code.py
  66. 23 5
      nicegui/elements/color_input.py
  67. 4 1
      nicegui/elements/color_picker.py
  68. 2 2
      nicegui/elements/column.py
  69. 2 2
      nicegui/elements/dialog.py
  70. 26 0
      nicegui/elements/echart.js
  71. 56 0
      nicegui/elements/echart.py
  72. 25 0
      nicegui/elements/editor.py
  73. 14 6
      nicegui/elements/expansion.py
  74. 2 2
      nicegui/elements/grid.py
  75. 5 5
      nicegui/elements/icon.py
  76. 1 0
      nicegui/elements/image.py
  77. 21 11
      nicegui/elements/interactive_image.js
  78. 13 12
      nicegui/elements/interactive_image.py
  79. 1 1
      nicegui/elements/joystick.py
  80. 38 0
      nicegui/elements/json_editor.js
  81. 43 0
      nicegui/elements/json_editor.py
  82. 3 2
      nicegui/elements/keyboard.py
  83. 2 2
      nicegui/elements/knob.py
  84. 0 0
      nicegui/elements/lib/echarts/echarts.js.map
  85. 34 0
      nicegui/elements/lib/echarts/echarts.min.js
  86. 220 0
      nicegui/elements/lib/three/modules/DragControls.js
  87. 0 0
      nicegui/elements/lib/vanilla-jsoneditor/index.js
  88. 0 0
      nicegui/elements/lib/vanilla-jsoneditor/index.js.map
  89. 24 3
      nicegui/elements/line_plot.py
  90. 1 1
      nicegui/elements/link.py
  91. 6 2
      nicegui/elements/log.py
  92. 1 1
      nicegui/elements/markdown.py
  93. 3 2
      nicegui/elements/menu.py
  94. 2 2
      nicegui/elements/mermaid.py
  95. 0 0
      nicegui/elements/mixins/__init__.py
  96. 5 5
      nicegui/elements/mixins/color_elements.py
  97. 5 5
      nicegui/elements/mixins/filter_element.py
  98. 80 0
      nicegui/elements/mixins/name_element.py
  99. 1 1
      nicegui/elements/mixins/source_element.py
  100. 7 3
      nicegui/elements/mixins/validation_element.py

+ 35 - 0
.devcontainer/Dockerfile

@@ -0,0 +1,35 @@
+FROM python:3.8
+
+ENV POETRY_VERSION=1.6.1 \
+    POETRY_NO_INTERACTION=1 \
+    POETRY_VIRTUALENVS_IN_PROJECT=false \
+    POETRY_VIRTUALENVS_CREATE=false \
+    DEBIAN_FRONTEND=noninteractive \
+    DISPLAY=:99
+
+# Install packages
+RUN apt-get update && apt-get install --no-install-recommends -y \
+    sudo git build-essential chromium chromium-driver \
+    && rm -rf /var/lib/apt/lists/*
+
+# Create remote user
+ARG USERNAME=vscode
+ARG USER_UID=1000
+ARG USER_GID=$USER_UID
+
+RUN groupadd --gid $USER_GID $USERNAME \
+    && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME \
+    && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \
+    && chmod 0440 /etc/sudoers.d/$USERNAME
+
+ENV PATH="/home/${USERNAME}/.local/bin:${PATH}"
+ENV CHROME_BINARY_LOCATION=/usr/bin/chromium
+
+# Install nicegui
+RUN pip install -U pip && pip install poetry==$POETRY_VERSION
+COPY nicegui pyproject.toml poetry.lock README.md ./
+RUN poetry install --all-extras
+
+USER $USERNAME
+
+ENTRYPOINT ["poetry", "run", "python", "-m", "debugpy", "--listen" ,"5678", "main.py"]

+ 39 - 0
.devcontainer/devcontainer.json

@@ -0,0 +1,39 @@
+// For format details, see https://aka.ms/devcontainer.json.
+{
+  "name": "nicegui-dev",
+  "build": {
+    "context": "..",
+    "dockerfile": "Dockerfile"
+  },
+  "customizations": {
+    "vscode": {
+      "extensions": [
+        "cschleiden.vscode-github-actions",
+        "esbenp.prettier-vscode",
+        "littlefoxteam.vscode-python-test-adapter",
+        "ms-python.autopep8",
+        "ms-python.isort",
+        "ms-python.mypy-type-checker",
+        "ms-python.pylint",
+        "ms-python.python",
+        "ms-python.vscode-pylance",
+        "samuelcolvin.jinjahtml",
+        "Vue.volar"
+      ],
+      "settings": {
+        "terminal.integrated.defaultProfile.linux": "bash",
+        "terminal.integrated.shell.linux": "bash",
+        "terminal.integrated.profiles.linux": {
+          "bash (container default)": {
+            "path": "/usr/bin/bash",
+            "overrideName": true
+          }
+        }
+      }
+      }
+    }
+  },
+  // More info: https://aka.ms/dev-containers-non-root.
+  "remoteUser": "vscode",
+  "postCreateCommand": "poetry install --all-extras"
+}

+ 5 - 0
.dockerignore

@@ -4,8 +4,13 @@
 **/.*.swp
 **/dist
 test.py
+demo.py
 **/*.pickle
 **/tests/screenshots
+**/.venv
+**/.pytest_cache
+**/.coverage
+**/.git
 
 # flyctl launch added from .pytest_cache/.gitignore
 # Created by pytest automatically.

+ 4 - 0
.github/ISSUE_TEMPLATE/issue.yml

@@ -6,6 +6,10 @@ body:
     attributes:
       label: Description
       description: |
+        Make sure it is really an issue (see [FAQs](/zauberzeug/nicegui/wiki/FAQs)).
+        A lot of people will read your message.
+        Make it worth their time.
+        
         1. What are you trying to do?
           If possible, give a [minimal reproducible code example](https://en.wikipedia.org/wiki/Minimal_reproducible_example).
           Put source code in [fenced code blocks with syntax highlighting](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks#syntax-highlighting).

+ 1 - 1
.github/workflows/test.yml

@@ -32,7 +32,7 @@ jobs:
       - name: test startup
         run: ./test_startup.sh
       - name: setup chromedriver
-        uses: nanasess/setup-chromedriver@v1
+        uses: nanasess/setup-chromedriver@v2.2.0
       - name: pytest
         run: pytest
       - name: upload screenshots

+ 7 - 6
.github/workflows/update_citation.py

@@ -1,11 +1,13 @@
 import os
 import sys
+from pathlib import Path
+from typing import Tuple
 
 import requests
 import yaml
 
 
-def get_infos() -> str:
+def get_infos() -> Tuple[str]:
     headers = {
         'Accept': 'application/json',
     }
@@ -16,7 +18,7 @@ def get_infos() -> str:
         'status': 'published',
     }
     try:
-        response = requests.get('https://zenodo.org/api/records', params=params, headers=headers)
+        response = requests.get('https://zenodo.org/api/records', params=params, headers=headers, timeout=5)
         response.raise_for_status()
     # Hide all error details to avoid leaking the token
     except Exception:
@@ -27,8 +29,7 @@ def get_infos() -> str:
 
 
 if __name__ == '__main__':
-    with open('CITATION.cff', 'r') as file:
-        citation = yaml.safe_load(file)
+    path = Path('CITATION.cff')
+    citation = yaml.safe_load(path.read_text())
     citation['doi'], citation['version'], citation['date-released'] = get_infos()
-    with open('CITATION.cff', 'w') as file:
-        yaml.dump(citation, file, sort_keys=False, default_flow_style=False)
+    path.write_text(yaml.dump(citation, sort_keys=False, default_flow_style=False))

+ 2 - 1
.gitignore

@@ -9,4 +9,5 @@ tests/media/
 venv
 .idea
 .nicegui/
-*.sqlite*
+*.sqlite*
+.DS_Store

+ 21 - 4
.vscode/settings.json

@@ -1,16 +1,33 @@
 {
+  "autopep8.args": ["--max-line-length=120"],
   "editor.defaultFormatter": "esbenp.prettier-vscode",
   "editor.formatOnSave": true,
-  "editor.minimap.enabled": false,
   "isort.args": ["--line-length", "120"],
   "prettier.printWidth": 120,
-  "python.formatting.provider": "autopep8",
-  "python.formatting.autopep8Args": ["--max-line-length=120", "--experimental"],
+  "pylint.args": [
+    "--disable=C0103", // Invalid name (e.g., variable/function/class naming conventions)
+    "--disable=C0111", // Missing docstring (in function/class/method)
+    "--disable=C0301", // Line too long (exceeds character limit)
+    "--disable=C0302", // Too many lines in module
+    "--disable=R0801", // Similar lines in files
+    "--disable=R0902", // Too many instance attributes
+    "--disable=R0903", // Too few public methods
+    "--disable=R0904", // Too many public methods
+    "--disable=R0911", // Too many return statements
+    "--disable=R0912", // Too many branches
+    "--disable=R0913", // Too many arguments
+    "--disable=R0914", // Too many local variables
+    "--disable=R0915", // Too many statements
+    "--disable=W0102", // Dangerous default value as argument
+    "--disable=W0718", // Catching too general exception
+    "--disable=W1203", // Use % formatting in logging functions
+    "--disable=W1514" // Using open without explicitly specifying an encoding
+  ],
   "python.testing.pytestArgs": ["."],
   "python.testing.pytestEnabled": true,
   "python.testing.unittestEnabled": false,
   "[python]": {
-    "editor.defaultFormatter": "ms-python.python",
+    "editor.defaultFormatter": "ms-python.autopep8",
     "editor.codeActionsOnSave": {
       "source.organizeImports": true
     }

+ 3 - 3
CITATION.cff

@@ -8,7 +8,7 @@ authors:
   given-names: Rodja
   orcid: https://orcid.org/0009-0009-4735-6227
 title: 'NiceGUI: Web-based user interfaces with Python. The nice way.'
-version: v1.3.5
-date-released: '2023-07-19'
+version: v1.3.16
+date-released: '2023-10-06'
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.8162736
+doi: 10.5281/zenodo.8413612

+ 15 - 4
CONTRIBUTING.md

@@ -21,7 +21,19 @@ We're always looking for bug fixes, performance improvements, and new features.
 
 ## Setup
 
-To set up a local development environment for NiceGUI, you'll need to have Python 3 and pip installed.
+### Dev Container
+
+The simplest way to setup a fully functioning development environment is to start our Dev Container in VS Code:
+
+1. Ensure you have VS Code, Docker and the Remote-Containers extension installed.
+2. Open the project root directory in VS Code.
+3. Press `F1`, type `Remote-Containers: Open Folder in Container`, and hit enter (or use the bottom-left corner icon in VS Code to reopen in container).
+4. Wait until image has been build.
+5. Happy coding.
+
+### Locally
+
+To set up a local development environment for NiceGUI, you'll need to have Python 3.8+ and pip installed.
 
 You can then use the following command to install NiceGUI in editable mode:
 
@@ -39,10 +51,9 @@ This means we sometimes miss some incompatibilities with older versions.
 But these will hopefully be uncovered by the GitHub Actions (see below).
 Also we use the 3.8 Docker container described below to verify compatibility in cases of uncertainty.
 
-### Alternative: Docker
+### Plain Docker
 
-You can also use Docker for development.
-Simply start the development container using the command:
+You can also use Docker for development by starting the development container using the command:
 
 ```bash
 ./docker.sh up app

+ 15 - 13
DEPENDENCIES.md

@@ -1,15 +1,17 @@
 # Included Web Dependencies
 
-- vue: 3.3.4
-- quasar: 2.12.2
-- tailwindcss: 3.3.2
-- socket.io: 4.7.1
-- es-module-shims: 1.7.3
-- aggrid: 30.0.3
-- highcharts: 11.1.0
-- leaflet: 1.9.4
-- mermaid: 10.2.4
-- nipplejs: 0.10.1
-- plotly: 2.24.3
-- three: 0.154.0
-- tween: 21.0.0
+- vue: 3.3.4 ([MIT](https://opensource.org/licenses/MIT))
+- quasar: 2.12.2 ([MIT](https://opensource.org/licenses/MIT))
+- tailwindcss: 3.3.2 ([MIT](https://opensource.org/licenses/MIT))
+- socket.io: 4.7.1 ([MIT](https://opensource.org/licenses/MIT))
+- es-module-shims: 1.7.3 ([MIT](https://opensource.org/licenses/MIT))
+- aggrid: 30.0.3 ([MIT](https://opensource.org/licenses/MIT))
+- echarts: 5.4.3 ([Apache-2.0](https://opensource.org/licenses/Apache-2.0))
+- highcharts: 11.1.0 ([https://www.highcharts.com/license](https://www.highcharts.com/license))
+- leaflet: 1.9.4 ([BSD-2-Clause](https://opensource.org/licenses/BSD-2-Clause))
+- mermaid: 10.2.4 ([MIT](https://opensource.org/licenses/MIT))
+- nipplejs: 0.10.1 ([MIT](https://opensource.org/licenses/MIT))
+- plotly: 2.24.3 ([MIT](https://opensource.org/licenses/MIT))
+- three: 0.154.0 ([MIT](https://opensource.org/licenses/MIT))
+- tween: 21.0.0 ([MIT](https://opensource.org/licenses/MIT))
+- vanilla-jsoneditor: 0.18.0 ([ISC](https://opensource.org/licenses/ISC))

+ 1 - 0
README.md

@@ -86,6 +86,7 @@ The documentation is hosted at [https://nicegui.io/documentation](https://nicegu
 The whole content of [https://nicegui.io](https://nicegui.io) is [implemented with NiceGUI itself](https://github.com/zauberzeug/nicegui/blob/main/main.py).
 
 You may also have a look at our [in-depth examples](https://github.com/zauberzeug/nicegui/tree/main/examples) of what you can do with NiceGUI.
+In our wiki we have a list of great [NiceGUI projects from the community](https://github.com/zauberzeug/nicegui/wiki#community-projects), a section with [Tutorials](https://github.com/zauberzeug/nicegui/wiki#tutorials), a growing list of [FAQs](https://github.com/zauberzeug/nicegui/wiki/FAQs) and [some strategies for using ChatGPT / LLMs to get help about NiceGUI](https://github.com/zauberzeug/nicegui/wiki#chatgpt).
 
 ## Why?
 

+ 1 - 1
deploy.sh

@@ -2,4 +2,4 @@
 pushd website
 ./build_search_index.py
 popd
-fly deploy --build-arg VERSION=$(git describe --abbrev=0 --tags --match 'v*' 2>/dev/null | sed 's/^v//' || echo '0.0.0') 
+fly deploy --wait-timeout 360 --build-arg VERSION=$(git describe --abbrev=0 --tags --match 'v*' 2>/dev/null | sed 's/^v//' || echo '0.0.0') 

+ 40 - 0
docker-entrypoint.sh

@@ -0,0 +1,40 @@
+#!/bin/bash
+
+# Get the PUID and PGID from environment variables (or use default values 1000 if not set)
+PUID=${PUID:-1000}
+PGID=${PGID:-1000}
+
+# Check if the provided PUID and PGID are non-empty, numeric values; otherwise, assign default values.
+if ! [[ "$PUID" =~ ^[0-9]+$ ]]; then
+  PUID=1000
+fi
+if ! [[ "$PGID" =~ ^[0-9]+$ ]]; then
+  PGID=1000
+fi
+# Check if the specified group with PGID exists, if not, create it.
+if ! getent group "$PGID" >/dev/null; then
+  groupadd -g "$PGID" appgroup
+fi
+# Create user if it doesn't exist.
+if ! getent passwd "$PUID" >/dev/null; then
+  useradd --create-home --shell /bin/bash --uid "$PUID" --gid "$PGID" appuser
+fi
+# Make user the owner of the app directory.
+chown -R appuser:appgroup /app
+# Copy the default .bashrc file to the appuser home directory.
+cp /etc/skel/.bashrc /home/appuser/.bashrc
+chown appuser:appgroup /home/appuser/.bashrc
+export HOME=/home/appuser
+# Set permissions on font directories.
+if [ -d "/usr/share/fonts" ]; then
+  chmod -R 777 /usr/share/fonts
+fi
+if [ -d "/var/cache/fontconfig" ]; then
+  chmod -R 777 /var/cache/fontconfig
+fi
+if [ -d "/usr/local/share/fonts" ]; then
+  chmod -R 777 /usr/local/share/fonts
+fi
+# Switch to appuser and execute the Docker CMD or passed in command-line arguments.
+# Using setpriv let's it run as PID 1 which is required for proper signal handling (similar to gosu/su-exec).
+exec setpriv --reuid=$PUID --regid=$PGID --init-groups $@

+ 12 - 12
docker.sh

@@ -33,43 +33,43 @@ cmd=$1
 cmd_args=${@:2}
 case $cmd in
     b | build)
-        docker-compose build $cmd_args
+        docker compose build $cmd_args
         ;;
     u | up)
-        docker-compose up -d $cmd_args
+        docker compose up -d $cmd_args
         ;;
     U | buildup | upbuild | upb | bup | ub)
-        docker-compose up -d --build $cmd_args
+        docker compose up -d --build $cmd_args
         ;;
     d | down)
-        docker-compose down -d $cmd_args
+        docker compose down -d $cmd_args
         ;;
     s | start)
-        docker-compose start $cmd_args
+        docker compose start $cmd_args
         ;;
     r | restart)
-        docker-compose restart $cmd_args
+        docker compose restart $cmd_args
         ;;
     h | stop)
-        docker-compose stop $cmd_args
+        docker compose stop $cmd_args
         ;;
     rm)
-        docker-compose rm $cmd_args
+        docker compose rm $cmd_args
         ;;
     ps)
-        docker-compose ps $cmd_args
+        docker compose ps $cmd_args
         ;;
     stat | stats)
         docker stats $cmd_args
         ;;
     l | log | logs)
-        docker-compose logs -f --tail 100 $cmd_args app
+        docker compose logs -f --tail 100 $cmd_args app
         ;;
     e | exec)
-        docker-compose exec $cmd_args app
+        docker compose exec $cmd_args app
         ;;
     a | attach)
-        docker-compose exec $cmd_args app /bin/bash
+        docker compose exec $cmd_args app /bin/bash
         ;;
     prune)
         docker system prune

+ 4 - 12
examples/ai_interface/main.py

@@ -1,25 +1,17 @@
 #!/usr/bin/env python3
-import asyncio
-import functools
 import io
-from typing import Callable
 
 import replicate  # very nice API to run AI models; see https://replicate.com/
 
-from nicegui import ui
+from nicegui import run, ui
 from nicegui.events import UploadEventArguments
 
 
-async def io_bound(callback: Callable, *args: any, **kwargs: any):
-    '''Makes a blocking function awaitable; pass function as first parameter and its arguments as the rest'''
-    return await asyncio.get_event_loop().run_in_executor(None, functools.partial(callback, *args, **kwargs))
-
-
 async def transcribe(e: UploadEventArguments):
     transcription.text = 'Transcribing...'
     model = replicate.models.get('openai/whisper')
     version = model.versions.get('30414ee7c4fffc37e260fcab7842b5be470b9b840f2b608f5baa9bbef9a259ed')
-    prediction = await io_bound(version.predict, audio=io.BytesIO(e.content))
+    prediction = await run.io_bound(version.predict, audio=io.BytesIO(e.content.read()))
     text = prediction.get('transcription', 'no transcription')
     transcription.set_text(f'result: "{text}"')
 
@@ -28,14 +20,14 @@ async def generate_image():
     image.source = 'https://dummyimage.com/600x400/ccc/000000.png&text=building+image...'
     model = replicate.models.get('stability-ai/stable-diffusion')
     version = model.versions.get('db21e45d3f7023abc2a46ee38a23973f6dce16bb082a930b0c49861f96d1e5bf')
-    prediction = await io_bound(version.predict, prompt=prompt.value)
+    prediction = await run.io_bound(version.predict, prompt=prompt.value)
     image.source = prediction[0]
 
 # User Interface
 with ui.row().style('gap:10em'):
     with ui.column():
         ui.label('OpenAI Whisper (voice transcription)').classes('text-2xl')
-        ui.upload(on_upload=transcribe).style('width: 20em')
+        ui.upload(on_upload=transcribe, auto_upload=True).style('width: 20em')
         transcription = ui.label().classes('text-xl')
     with ui.column():
         ui.label('Stable Diffusion (image generator)').classes('text-2xl')

+ 32 - 5
examples/authentication/main.py

@@ -1,33 +1,60 @@
 #!/usr/bin/env python3
-"""This is just a very simple authentication example.
+"""This is just a simple authentication example.
 
 Please see the `OAuth2 example at FastAPI <https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/>`_  or
 use the great `Authlib package <https://docs.authlib.org/en/v0.13/client/starlette.html#using-fastapi>`_ to implement a classing real authentication system.
 Here we just demonstrate the NiceGUI integration.
 """
+from typing import Optional
+
+from fastapi import Request
 from fastapi.responses import RedirectResponse
+from starlette.middleware.base import BaseHTTPMiddleware
 
+import nicegui.globals
 from nicegui import app, ui
 
 # in reality users passwords would obviously need to be hashed
 passwords = {'user1': 'pass1', 'user2': 'pass2'}
 
+unrestricted_page_routes = {'/login'}
+
+
+class AuthMiddleware(BaseHTTPMiddleware):
+    """This middleware restricts access to all NiceGUI pages.
+
+    It redirects the user to the login page if they are not authenticated.
+    """
+
+    async def dispatch(self, request: Request, call_next):
+        if not app.storage.user.get('authenticated', False):
+            if request.url.path in nicegui.globals.page_routes.values() and request.url.path not in unrestricted_page_routes:
+                app.storage.user['referrer_path'] = request.url.path  # remember where the user wanted to go
+                return RedirectResponse('/login')
+        return await call_next(request)
+
+
+app.add_middleware(AuthMiddleware)
+
 
 @ui.page('/')
 def main_page() -> None:
-    if not app.storage.user.get('authenticated', False):
-        return RedirectResponse('/login')
     with ui.column().classes('absolute-center items-center'):
         ui.label(f'Hello {app.storage.user["username"]}!').classes('text-2xl')
         ui.button(on_click=lambda: (app.storage.user.clear(), ui.open('/login')), icon='logout').props('outline round')
 
 
+@ui.page('/subpage')
+def test_page() -> None:
+    ui.label('This is a sub page.')
+
+
 @ui.page('/login')
-def login() -> None:
+def login() -> Optional[RedirectResponse]:
     def try_login() -> None:  # local function to avoid passing username and password as arguments
         if passwords.get(username.value) == password.value:
             app.storage.user.update({'username': username.value, 'authenticated': True})
-            ui.open('/')
+            ui.open(app.storage.user.get('referrer_path', '/'))  # go back to where the user wanted to go
         else:
             ui.notify('Wrong username or password', color='negative')
 

+ 1 - 1
examples/chat_with_ai/main.py

@@ -10,7 +10,7 @@ OPENAI_API_KEY = 'not-set'  # TODO: set your OpenAI API key here
 
 llm = ConversationChain(llm=ChatOpenAI(model_name='gpt-3.5-turbo', openai_api_key=OPENAI_API_KEY))
 
-messages: List[Tuple[str, str, str]] = []
+messages: List[Tuple[str, str]] = []
 thinking: bool = False
 
 

+ 40 - 0
examples/custom_binding/main.py

@@ -0,0 +1,40 @@
+#!/usr/bin/env python3
+import random
+from typing import Optional
+
+from nicegui import ui
+from nicegui.binding import BindableProperty, bind_from
+
+
+class colorful_label(ui.label):
+    """A label with a bindable background color."""
+
+    # This class variable defines what happens when the background property changes.
+    background = BindableProperty(on_change=lambda sender, value: sender.on_background_change(value))
+
+    def __init__(self, text: str = '') -> None:
+        super().__init__(text)
+        self.background: Optional[str] = None  # initialize the background property
+
+    def on_background_change(self, bg_class: str) -> None:
+        """Update the classes of the label when the background property changes."""
+        self._classes = [c for c in self._classes if not c.startswith('bg-')]
+        self._classes.append(bg_class)
+        self.update()
+
+
+temperatures = {'Berlin': 5, 'New York': 15, 'Tokio': 25}
+ui.button(icon='refresh', on_click=lambda: temperatures.update({city: random.randint(0, 30) for city in temperatures}))
+
+
+for city in temperatures:
+    label = colorful_label().classes('w-48 text-center') \
+        .bind_text_from(temperatures, city, backward=lambda t, city=city: f'{city} ({t}°C)')
+    # Bind background color from temperature.
+    # There is also a bind_to method which would propagate changes from the label to the temperatures dictionary
+    # and a bind method which would propagate changes both ways.
+    bind_from(self_obj=label, self_name='background',
+              other_obj=temperatures, other_name=city,
+              backward=lambda t: 'bg-green' if t < 10 else 'bg-yellow' if t < 20 else 'bg-orange')
+
+ui.run()

+ 10 - 0
examples/descope_auth/README.md

@@ -0,0 +1,10 @@
+# Descope Auth Example
+
+Descope is an all-inclusive user authentication and user management platform.
+
+## Getting Started
+
+1. Create a [Descope](https://www.descope.com/) account.
+2. Setup a project and configure the "Login Flow" (fist step in the Getting Started Wizard).
+3. Instead of following the "Integrate" instructions of the Wizard, use this example.
+4. Provide your Descope Project ID as environment variable: `DESCOPE_PROJECT_ID=<your_project_id> python3 main.py`

+ 26 - 0
examples/descope_auth/main.py

@@ -0,0 +1,26 @@
+#!/usr/bin/env python3
+import json
+
+import user
+
+from nicegui import ui
+
+
+@user.login_page
+def login():
+    user.login_form().on('success', lambda: ui.open('/'))
+
+
+@user.page('/')
+def home():
+    ui.code(json.dumps(user.about(), indent=2), language='json')
+    ui.button('Logout', on_click=user.logout)
+
+
+@user.page('/async')
+async def async_page():
+    await ui.button('Wait for it...').clicked()
+    ui.label('This is an async page')
+
+
+ui.run(storage_secret='THIS_NEEDS_TO_BE_CHANGED')

+ 1 - 0
examples/descope_auth/requirements.txt

@@ -0,0 +1 @@
+descope

+ 105 - 0
examples/descope_auth/user.py

@@ -0,0 +1,105 @@
+import logging
+import os
+from typing import Any, Callable, Dict
+
+from descope import AuthException, DescopeClient
+
+from nicegui import Client, app, helpers, ui
+
+DESCOPE_ID = os.environ.get('DESCOPE_PROJECT_ID', '')
+
+try:
+    descope_client = DescopeClient(project_id=DESCOPE_ID)
+except AuthException as ex:
+    print(ex.error_message)
+
+
+def login_form() -> ui.element:
+    """Create and return the Descope login form."""
+    with ui.card().classes('w-96 mx-auto'):
+        return ui.element('descope-wc').props(f'project-id="{DESCOPE_ID}" flow-id="sign-up-or-in"') \
+            .on('success', lambda e: app.storage.user.update({'descope': e.args['detail']['user']}))
+
+
+def about() -> Dict[str, Any]:
+    """Return the user's Descope profile.
+
+    This function can only be used after the user has logged in.
+    """
+    return app.storage.user['descope']
+
+
+async def logout() -> None:
+    """Logout the user."""
+    result = await ui.run_javascript('return await sdk.logout()', respond=True)
+    if result['code'] == 200:
+        app.storage.user['descope'] = None
+    else:
+        logging.error(f'Logout failed: {result}')
+        ui.notify('Logout failed', type='negative')
+    ui.open(page.LOGIN_PATH)
+
+
+class page(ui.page):
+    """A page that requires the user to be logged in.
+
+    It allows the same parameters as ui.page, but adds a login check.
+    As recommended by Descope, this is done via JavaScript and allows to use Flows.
+    But this means that the page has already awaited the client connection.
+    So `ui.add_head_html` will not work.
+    """
+    SESSION_TOKEN_REFRESH_INTERVAL = 30
+    LOGIN_PATH = '/login'
+
+    def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]:
+        async def content(client: Client):
+            ui.add_head_html('<script src="https://unpkg.com/@descope/web-component@latest/dist/index.js"></script>')
+            ui.add_head_html('<script src="https://unpkg.com/@descope/web-js-sdk@latest/dist/index.umd.js"></script>')
+            ui.add_body_html(f'''
+                <script>
+                    const sdk = Descope({{ projectId: '{DESCOPE_ID}', persistTokens: true, autoRefresh: true }});
+                    const sessionToken = sdk.getSessionToken()
+                </script>                 
+            ''')
+            await client.connected()
+            if await self._is_logged_in():
+                if self.path == self.LOGIN_PATH:
+                    await self._refresh()
+                    ui.open('/')
+                    return
+            else:
+                if self.path != self.LOGIN_PATH:
+                    ui.open(self.LOGIN_PATH)
+                    return
+                ui.timer(self.SESSION_TOKEN_REFRESH_INTERVAL, self._refresh)
+
+            if helpers.is_coroutine_function(func):
+                await func()
+            else:
+                func()
+
+        return super().__call__(content)
+
+    @staticmethod
+    async def _is_logged_in() -> bool:
+        if not app.storage.user.get('descope'):
+            return False
+        token = await ui.run_javascript('return sessionToken && !sdk.isJwtExpired(sessionToken) ? sessionToken : null;')
+        if not token:
+            return False
+        try:
+            descope_client.validate_session(session_token=token)
+            return True
+        except AuthException:
+            logging.exception('Could not validate user session.')
+            ui.notify('Wrong username or password', type='negative')
+            return False
+
+    @staticmethod
+    async def _refresh() -> None:
+        await ui.run_javascript('sdk.refresh()', respond=False)
+
+
+def login_page(func: Callable[..., Any]) -> Callable[..., Any]:
+    """Marks the special page that will contain the login form."""
+    return page(page.LOGIN_PATH)(func)

+ 37 - 0
examples/docker_image/README.md

@@ -0,0 +1,37 @@
+# Docker Example with NiceGUI
+
+This README provides a walkthrough on how to utilize the NiceGUI release docker image, [zauberzeug/nicegui, available on Docker Hub](https://hub.docker.com/r/zauberzeug/nicegui).
+The image is configured using a `docker-compose.yml` file for ease of use.
+You can achieve similar results using the `docker run` command along with its appropriate parameters.
+
+## Testing the Setup
+
+Modify the `docker-compose.yml` file to reflect your local host user's uid/gid and then execute the command:
+
+```bash
+docker compose up
+```
+
+## Special Docker Features
+
+### Data Persistence
+
+NiceGUI automatically generates a `.nicegui` directory in the application's root directory (`/app` within the docker container).
+In this example, the local `app` folder is mounted to the `/app` location inside the container, ensuring that the `.nicegui` folder remains persistent across docker restarts.
+You can validate this by accessing http://localhost:8080, inputting some data for storage, and then restarting the container.
+
+### Non-Root User Execution
+
+The application within the container operates as a non-root user (similar to the [configs from linuxserver.io](https://docs.linuxserver.io/general/understanding-puid-and-pgid)).
+Consequently, all files generated by NiceGUI (such as the `.nicegui` persistence) will bear the configured uid/gid.
+
+### Docker Signal Pass-Through
+
+The docker image is designed to relay signals from Docker, such as SIGTERM, to initiate a graceful shutdown of NiceGUI.
+For instance, when you stop the container (using Ctrl+C) and subsequently examine the logs using the `docker compose logs` command,
+you should notice the initiation of the `ui.shutdown` method.
+
+### Storage Secret
+
+In the example `main.py` we read the [storage secret](https://nicegui.io/documentation/storage) from a environment variable.
+This can then be defined in the `docker-compose.yml` (or even passed on from an `.env` file).

+ 17 - 0
examples/docker_image/app/main.py

@@ -0,0 +1,17 @@
+import os
+
+from nicegui import app, ui
+
+
+@ui.page('/')
+def index():
+    ui.textarea('This note is kept between visits') \
+        .classes('w-96').bind_value(app.storage.user, 'note')
+
+
+def on_shutdown():
+    print('Shutdown has been initiated!')
+
+
+app.on_shutdown(on_shutdown)
+ui.run(storage_secret=os.environ['STORAGE_SECRET'])

+ 13 - 0
examples/docker_image/docker-compose.yml

@@ -0,0 +1,13 @@
+version: "3.9"
+
+services:
+  nicegui:
+    image: zauberzeug/nicegui:latest
+    ports:
+      - 8080:8080
+    volumes:
+      - ./app:/app # mounting local app directory
+    environment:
+      - PUID=1000 # change this to your user id
+      - PGID=1000 # change this to your group id
+      - STORAGE_SECRET="change-this-to-yor-own-private-secret"

+ 27 - 0
examples/download_text_as_file/main.py

@@ -0,0 +1,27 @@
+#!/usr/bin/env python3
+import io
+import uuid
+
+from fastapi.responses import StreamingResponse
+
+from nicegui import Client, app, ui
+
+
+@ui.page('/')
+async def index(client: Client):
+    download_path = f'/download/{uuid.uuid4()}.txt'
+
+    @app.get(download_path)
+    def download():
+        string_io = io.StringIO(textarea.value)  # create a file-like object from the string
+        headers = {'Content-Disposition': 'attachment; filename=download.txt'}
+        return StreamingResponse(string_io, media_type='text/plain', headers=headers)
+
+    textarea = ui.textarea(value='Hello World!')
+    ui.button('Download', on_click=lambda: ui.download(download_path))
+
+    # cleanup the download route after the client disconnected
+    await client.disconnected()
+    app.routes[:] = [route for route in app.routes if route.path != download_path]
+
+ui.run()

+ 1 - 0
examples/generate_pdf/.gitignore

@@ -0,0 +1 @@
+*.pdf

+ 51 - 0
examples/generate_pdf/main.py

@@ -0,0 +1,51 @@
+#!/usr/bin/env python3
+from io import BytesIO
+from pathlib import Path
+
+import cairo
+
+from nicegui import ui
+
+PDF_PATH = Path('output.pdf')
+
+
+def generate_svg() -> str:
+    output = BytesIO()
+    surface = cairo.SVGSurface(output, 300, 200)
+    draw(surface)
+    surface.finish()
+    return output.getvalue().decode('utf-8')
+
+
+def generate_pdf() -> bytes:
+    output = BytesIO()
+    surface = cairo.PDFSurface(output, 300, 200)
+    draw(surface)
+    surface.finish()
+    return output.getvalue()
+
+
+def draw(surface: cairo.SVGSurface) -> None:
+    context = cairo.Context(surface)
+    context.select_font_face('Arial', cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
+    context.set_font_size(20)
+    context.move_to(10, 40)
+    context.show_text(name.value)
+    context.move_to(10, 80)
+    context.show_text(email.value)
+
+
+def update() -> None:
+    preview.content = generate_svg()
+    PDF_PATH.write_bytes(generate_pdf())
+
+
+with ui.row():
+    with ui.column():
+        name = ui.input('Name', placeholder='Enter your name', on_change=update)
+        email = ui.input('E-Mail', placeholder='Enter your E-Mail address', on_change=update)
+    preview = ui.html().classes('border-2 border-gray-500')
+    update()
+    ui.button('Download PDF', on_click=lambda: ui.download(PDF_PATH)).bind_visibility_from(name, 'value')
+
+ui.run()

+ 1 - 1
examples/search_as_you_type/requirements.txt → examples/generate_pdf/requirements.txt

@@ -1,2 +1,2 @@
 nicegui
-httpx
+pycairo

+ 3 - 10
examples/opencv_webcam/main.py

@@ -1,7 +1,5 @@
 #!/usr/bin/env python3
-import asyncio
 import base64
-import concurrent.futures
 import signal
 import time
 
@@ -10,10 +8,8 @@ import numpy as np
 from fastapi import Response
 
 import nicegui.globals
-from nicegui import app, ui
+from nicegui import app, run, ui
 
-# We need an executor to schedule CPU-intensive tasks with `loop.run_in_executor()`.
-process_pool_executor = concurrent.futures.ProcessPoolExecutor()
 # In case you don't have a webcam, this will provide a black placeholder image.
 black_1px = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjYGBg+A8AAQQBAHAgZQsAAAAASUVORK5CYII='
 placeholder = Response(content=base64.b64decode(black_1px.encode('ascii')), media_type='image/png')
@@ -31,14 +27,13 @@ def convert(frame: np.ndarray) -> bytes:
 async def grab_video_frame() -> Response:
     if not video_capture.isOpened():
         return placeholder
-    loop = asyncio.get_running_loop()
     # The `video_capture.read` call is a blocking function.
     # So we run it in a separate thread (default executor) to avoid blocking the event loop.
-    _, frame = await loop.run_in_executor(None, video_capture.read)
+    _, frame = await run.io_bound(video_capture.read)
     if frame is None:
         return placeholder
     # `convert` is a CPU-intensive function, so we run it in a separate process to avoid blocking the event loop and GIL.
-    jpeg = await loop.run_in_executor(process_pool_executor, convert, frame)
+    jpeg = await run.cpu_bound(convert, frame)
     return Response(content=jpeg, media_type='image/jpeg')
 
 # For non-flickering image updates an interactive image is much better than `ui.image()`.
@@ -68,8 +63,6 @@ async def cleanup() -> None:
     await disconnect()
     # Release the webcam hardware so it can be used by other applications again.
     video_capture.release()
-    # The process pool executor must be shutdown when the app is closed, otherwise the process will not exit.
-    process_pool_executor.shutdown()
 
 app.on_shutdown(cleanup)
 # We also need to disconnect clients when the app is stopped with Ctrl+C,

+ 3 - 12
examples/progress/main.py

@@ -1,16 +1,12 @@
 #!/usr/bin/env python3
-import asyncio
 import time
-from concurrent.futures import ProcessPoolExecutor
 from multiprocessing import Manager, Queue
 
-from nicegui import app, ui
-
-pool = ProcessPoolExecutor()
+from nicegui import run, ui
 
 
 def heavy_computation(q: Queue) -> str:
-    '''Some heavy computation that updates the progress bar through the queue.'''
+    """Run some heavy computation that updates the progress bar through the queue."""
     n = 50
     for i in range(n):
         # Perform some heavy computation
@@ -23,11 +19,9 @@ def heavy_computation(q: Queue) -> str:
 
 @ui.page('/')
 def main_page():
-
     async def start_computation():
         progressbar.visible = True
-        loop = asyncio.get_running_loop()
-        result = await loop.run_in_executor(pool, heavy_computation, queue)
+        result = await run.cpu_bound(heavy_computation, queue)
         ui.notify(result)
         progressbar.visible = False
 
@@ -42,7 +36,4 @@ def main_page():
     progressbar.visible = False
 
 
-# stop the pool when the app is closed; will not cancel any running tasks
-app.on_shutdown(pool.shutdown)
-
 ui.run()

+ 3 - 1
examples/script_executor/main.py

@@ -3,6 +3,7 @@ import asyncio
 import os.path
 import platform
 import shlex
+import sys
 
 from nicegui import ui
 
@@ -11,8 +12,9 @@ async def run_command(command: str) -> None:
     """Run a command in the background and display the output in the pre-created dialog."""
     dialog.open()
     result.content = ''
+    command = command.replace('python3', sys.executable)  # NOTE replace with machine-independent Python path (#1240)
     process = await asyncio.create_subprocess_exec(
-        *shlex.split(command),
+        *shlex.split(command, posix="win" not in sys.platform.lower()),
         stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
         cwd=os.path.dirname(os.path.abspath(__file__))
     )

+ 2 - 0
examples/slideshow/main.py

@@ -4,6 +4,8 @@ from pathlib import Path
 from nicegui import app, ui
 from nicegui.events import KeyEventArguments
 
+ui.query('.nicegui-content').classes('p-0')  # remove padding from the main content
+
 folder = Path(__file__).parent / 'slides'  # image source: https://pixabay.com/
 files = sorted(f.name for f in folder.glob('*.jpg'))
 index = 0

+ 2 - 2
fetch_google_fonts.py

@@ -7,9 +7,9 @@ import requests
 AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36'
 
 url = 'https://fonts.googleapis.com/css2?family=Material+Icons&family=Roboto:wght@100;300;400;500;700;900'
-css = requests.get(url, headers={'User-Agent': AGENT}).content.decode()
+css = requests.get(url, headers={'User-Agent': AGENT}, timeout=5).content.decode()
 for font_url in re.findall(r'url\((.*?)\)', css):
-    font = requests.get(font_url).content
+    font = requests.get(font_url, timeout=5).content
     (Path('nicegui/static/fonts') / font_url.split('/')[-1]).write_bytes(font)
 css = css.replace('https://fonts.gstatic.com/s/materialicons/v140', 'fonts')
 css = css.replace('https://fonts.gstatic.com/s/roboto/v30', 'fonts')

+ 54 - 0
fetch_milestone.py

@@ -0,0 +1,54 @@
+#!/usr/bin/env python3
+import argparse
+import re
+import sys
+
+import requests
+
+BASE_URL = 'https://api.github.com/repos/zauberzeug/nicegui'
+
+parser = argparse.ArgumentParser(description='Fetch the content of a milestone from a GitHub repo.')
+parser.add_argument('milestone_title', help='Title of the milestone to fetch.')
+args = parser.parse_args()
+milestone_title: str = args.milestone_title
+
+milestones = requests.get(f'{BASE_URL}/milestones', timeout=5).json()
+matching_milestones = [milestone for milestone in milestones if milestone['title'] == milestone_title]
+if not matching_milestones:
+    print(f'Milestone "{milestone_title}" not found!')
+    sys.exit(1)
+milestone_number = matching_milestones[0]['number']
+
+issues = requests.get(f'{BASE_URL}/issues?milestone={milestone_number}&state=all', timeout=5).json()
+notes = {
+    'New features and enhancements': [],
+    'Bugfixes': [],
+    'Documentation': [],
+    'Others': [],
+}
+for issue in issues:
+    title: str = issue['title']
+    user: str = issue['user']['login']
+    body: str = issue['body']
+    labels: list[str] = [label['name'] for label in issue['labels']]
+    number_patterns = [r'#(\d+)', r'https://github.com/zauberzeug/nicegui/(?:issues|discussions|pulls)/(\d+)']
+    numbers = [issue['number']] + [int(match) for pattern in number_patterns for match in re.findall(pattern, body)]
+    numbers_str = ', '.join(f'#{number}' for number in sorted(numbers))
+    note = f'{title.strip()} ({numbers_str} by @{user})'
+    if 'bug' in labels:
+        notes['Bugfixes'].append(note)
+    elif 'enhancement' in labels:
+        notes['New features and enhancements'].append(note)
+    elif 'documentation' in labels:
+        notes['Documentation'].append(note)
+    else:
+        notes['Others'].append(note)
+
+for title, notes in notes.items():
+    if not notes:
+        continue
+    print(f'### {title}')
+    print()
+    for note in notes:
+        print(f'- {note}')
+    print()

+ 31 - 25
fetch_tailwind.py

@@ -18,7 +18,7 @@ class Property:
 
     def __post_init__(self) -> None:
         words = [s.split('-') for s in self.members]
-        prefix = words[0]
+        prefix = words[0]  # pylint: disable=redefined-outer-name
         for w in words:
             i = 0
             while i < len(prefix) and i < len(w) and prefix[i] == w[i]:
@@ -57,7 +57,7 @@ def get_soup(url: str) -> BeautifulSoup:
     if path.exists():
         html = path.read_text()
     else:
-        req = requests.get(url)
+        req = requests.get(url, timeout=5)
         html = req.text
         path.write_text(html)
     return BeautifulSoup(html, 'html.parser')
@@ -80,28 +80,30 @@ for li in soup.select('li[class="mt-12 lg:mt-8"]'):
 
 for file in (Path(__file__).parent / 'nicegui' / 'tailwind_types').glob('*.py'):
     file.unlink()
-for property in properties:
-    if not property.members:
+(Path(__file__).parent / 'nicegui' / 'tailwind_types' / '__init__.py').touch()
+for property_ in properties:
+    if not property_.members:
         continue
-    with (Path(__file__).parent / 'nicegui' / 'tailwind_types' / f'{property.snake_title}.py').open('w') as f:
+    with (Path(__file__).parent / 'nicegui' / 'tailwind_types' / f'{property_.snake_title}.py').open('w') as f:
         f.write('from typing import Literal\n')
         f.write('\n')
-        f.write(f'{property.pascal_title} = Literal[\n')
-        for short_member in property.short_members:
+        f.write(f'{property_.pascal_title} = Literal[\n')
+        for short_member in property_.short_members:
             f.write(f"    '{short_member}',\n")
         f.write(']\n')
 
 with (Path(__file__).parent / 'nicegui' / 'tailwind.py').open('w') as f:
+    f.write('# pylint: disable=too-many-lines\n')
     f.write('from __future__ import annotations\n')
     f.write('\n')
     f.write('from typing import TYPE_CHECKING, List, Optional, Union, overload\n')
     f.write('\n')
     f.write('if TYPE_CHECKING:\n')
     f.write('    from .element import Element\n')
-    for property in sorted(properties, key=lambda p: p.title):
-        if not property.members:
+    for property_ in sorted(properties, key=lambda p: p.title):
+        if not property_.members:
             continue
-        f.write(f'    from .tailwind_types.{property.snake_title} import {property.pascal_title}\n')
+        f.write(f'    from .tailwind_types.{property_.snake_title} import {property_.pascal_title}\n')
     f.write('\n')
     f.write('\n')
     f.write('class PseudoElement:\n')
@@ -115,7 +117,7 @@ with (Path(__file__).parent / 'nicegui' / 'tailwind.py').open('w') as f:
     f.write('\n')
     f.write('class Tailwind:\n')
     f.write('\n')
-    f.write("    def __init__(self, _element: Optional['Element'] = None) -> None:\n")
+    f.write("    def __init__(self, _element: Optional[Element] = None) -> None:\n")
     f.write('        self.element: Union[PseudoElement, Element] = PseudoElement() if _element is None else _element\n')
     f.write('\n')
     f.write('    @overload\n')
@@ -126,27 +128,31 @@ with (Path(__file__).parent / 'nicegui' / 'tailwind.py').open('w') as f:
     f.write('    def __call__(self, *classes: str) -> Tailwind:\n')
     f.write('        ...\n')
     f.write('\n')
-    f.write('    def __call__(self, *args) -> Tailwind:\n')
+    f.write('    def __call__(self, *args) -> Tailwind:  # type: ignore\n')
     f.write('        if not args:\n')
     f.write('            return self\n')
     f.write('        if isinstance(args[0], Tailwind):\n')
-    f.write('            args[0].apply(self.element)\n')
+    f.write('            args[0].apply(self.element)  # type: ignore\n')
     f.write('        else:\n')
     f.write("            self.element.classes(' '.join(args))\n")
     f.write('        return self\n')
     f.write('\n')
-    f.write("    def apply(self, element: 'Element') -> None:\n")
-    f.write('        element._classes.extend(self.element._classes)\n')
+    f.write("    def apply(self, element: Element) -> None:\n")
+    f.write('        element._classes.extend(self.element._classes)  # pylint: disable=protected-access\n')
     f.write('        element.update()\n')
-    for property in properties:
+    for property_ in properties:
         f.write('\n')
-        if property.members:
-            f.write(f"    def {property.snake_title}(self, value: {property.pascal_title}) -> 'Tailwind':\n")
-            f.write(f'        """{property.description}"""\n')
-            f.write(f"        self.element.classes('{property.common_prefix}' + value)\n")
-            f.write(f'        return self\n')
+        prefix = property_.common_prefix
+        if property_.members:
+            f.write(f"    def {property_.snake_title}(self, value: {property_.pascal_title}) -> Tailwind:\n")
+            f.write(f'        """{property_.description}"""\n')
+            if '' in property_.short_members:
+                f.write(f"        self.element.classes('{prefix}' + value if value else '{prefix.rstrip('''-''')}')\n")
+            else:
+                f.write(f"        self.element.classes('{prefix}' + value)\n")
+            f.write(f'        return self\n')  # pylint: disable=f-string-without-interpolation
         else:
-            f.write(f"    def {property.snake_title}(self) -> 'Tailwind':\n")
-            f.write(f'        """{property.description}"""\n')
-            f.write(f"        self.element.classes('{property.common_prefix}')\n")
-            f.write(f'        return self\n')
+            f.write(f"    def {property_.snake_title}(self) -> Tailwind:\n")
+            f.write(f'        """{property_.description}"""\n')
+            f.write(f"        self.element.classes('{prefix}')\n")
+            f.write(f'        return self\n')  # pylint: disable=f-string-without-interpolation

+ 12 - 0
fly-entrypoint.sh

@@ -0,0 +1,12 @@
+#!/bin/bash
+set -e
+
+if [[ ! -z "$SWAP" ]]; then 
+  fallocate -l $(($(stat -f -c "(%a*%s/10)*7" .))) _swapfile
+  mkswap _swapfile 
+  swapon _swapfile
+fi
+
+free -hm
+df -h
+exec "$@"

+ 16 - 1
fly.dockerfile

@@ -2,10 +2,21 @@ FROM python:3.11.3-slim
 
 LABEL maintainer="Zauberzeug GmbH <nicegui@zauberzeug.com>"
 
+RUN apt update && apt install -y curl procps
+
 RUN pip install itsdangerous prometheus_client isort docutils pandas plotly matplotlib requests
 
+RUN curl -sSL https://install.python-poetry.org | python3 - && \
+    cd /usr/local/bin && \
+    ln -s ~/.local/bin/poetry && \
+    poetry config virtualenvs.create false
+
 WORKDIR /app
 
+COPY pyproject.toml poetry.lock*  ./
+
+RUN poetry install --no-root --extras "plotly matplotlib"
+
 ADD . .
 
 # ensure unique version to not serve cached and hence potentially wrong static files
@@ -18,4 +29,8 @@ RUN pip install .
 EXPOSE 8080
 EXPOSE 9062
 
-CMD python3 main.py
+COPY fly-entrypoint.sh /entrypoint.sh
+
+ENTRYPOINT ["/entrypoint.sh"]
+
+CMD ["python", "main.py"]

+ 28 - 28
fly.toml

@@ -1,60 +1,60 @@
+# fly.toml app configuration file generated for nicegui on 2023-10-05T05:09:32+02:00
+#
+# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
+#
+
 app = "nicegui"
-#app = "nicegui-preview"
+primary_region = "fra"
 kill_signal = "SIGTERM"
-kill_timeout = 5
-processes = []
+kill_timeout = "5s"
 
 [build]
   dockerfile = "fly.dockerfile"
 
-[env]
-
 [deploy]
-# boot a single, new VM with the new release, verify its health, then
-# One by one, each running VM is taken down and replaced by the new release VM
-strategy = "rolling" 
+  strategy = "bluegreen"
 
+[processes]
+  app = ""
 
-[experimental]
-  allowed_public_ports = []
-  auto_rollback = true
+[env]
+  SWAP = "true"
 
 [[services]]
+  protocol = "tcp"
   internal_port = 8080
   processes = ["app"]
-  protocol = "tcp"
-  script_checks = []
-  [services.concurrency]
-    hard_limit = 100
-    soft_limit = 12
-    type = "connections"
+  auto_stop_machines = true
+  auto_start_machines = true
+  min_machines_running = 6
 
   [[services.ports]]
-    force_https = true
-    handlers = ["http"]
     port = 80
+    handlers = ["http"]
+    force_https = true
 
   [[services.ports]]
-    handlers = ["tls", "http"]
     port = 443
+    handlers = ["tls", "http"]
+  [services.concurrency]
+    type = "connections"
+    hard_limit = 80
+    soft_limit = 30
 
   [[services.tcp_checks]]
     interval = "10s"
-    grace_period = "2m"
-    restart_limit = 3
     timeout = "5s"
+    grace_period = "30s"
 
   [[services.http_checks]]
     interval = "20s"
-    grace_period = "4m"
+    timeout = "10s"
+    grace_period = "1m0s"
     method = "get"
     path = "/"
     protocol = "http"
-    restart_limit = 3
-    timeout = "10s"
     tls_skip_verify = false
-    [services.http_checks.headers]
 
-  [metrics]
+[[metrics]]
   port = 9062
-  path = "/metrics" # default for most prometheus clients
+  path = "/metrics"

+ 55 - 14
main.py

@@ -1,20 +1,16 @@
 #!/usr/bin/env python3
 import importlib
 import inspect
-
-if True:
-    # increasing max decode packets to be able to transfer images
-    # see https://github.com/miguelgrinberg/python-engineio/issues/142
-    from engineio.payload import Payload
-    Payload.max_decode_packets = 500
-
 import os
 from pathlib import Path
 from typing import Awaitable, Callable, Optional
+from urllib.parse import parse_qs
 
 from fastapi import Request
 from fastapi.responses import FileResponse, RedirectResponse, Response
+from starlette.middleware.base import BaseHTTPMiddleware
 from starlette.middleware.sessions import SessionMiddleware
+from starlette.types import ASGIApp, Receive, Scope, Send
 
 import prometheus
 from nicegui import Client, app
@@ -36,6 +32,12 @@ app.add_static_files('/favicon', str(Path(__file__).parent / 'website' / 'favico
 app.add_static_files('/fonts', str(Path(__file__).parent / 'website' / 'fonts'))
 app.add_static_files('/static', str(Path(__file__).parent / 'website' / 'static'))
 
+if True:  # HACK: prevent the page from scrolling when closing a dialog (#1404)
+    def on_dialog_value_change(sender, value, on_value_change=ui.dialog.on_value_change) -> None:
+        ui.query('html').classes(**{'add' if value else 'remove': 'has-dialog'})
+        on_value_change(sender, value)
+    ui.dialog.on_value_change = on_dialog_value_change
+
 
 @app.get('/logo.png')
 def logo() -> FileResponse:
@@ -59,10 +61,42 @@ async def redirect_reference_to_documentation(request: Request,
         return RedirectResponse('/documentation')
     return await call_next(request)
 
-# NOTE in our global fly.io deployment we need to make sure that the websocket connects back to the same instance
-fly_instance_id = os.environ.get('FLY_ALLOC_ID', '').split('-')[0]
-if fly_instance_id:
-    nicegui_globals.socket_io_js_extra_headers['fly-force-instance-id'] = fly_instance_id
+# NOTE In our global fly.io deployment we need to make sure that we connect back to the same instance.
+fly_instance_id = os.environ.get('FLY_ALLOC_ID', 'local').split('-')[0]
+nicegui_globals.socket_io_js_extra_headers['fly-force-instance-id'] = fly_instance_id  # for HTTP long polling
+nicegui_globals.socket_io_js_query_params['fly_instance_id'] = fly_instance_id  # for websocket (FlyReplayMiddleware)
+
+
+class FlyReplayMiddleware(BaseHTTPMiddleware):
+    """Replay to correct fly.io instance.
+
+    If the wrong instance was picked by the fly.io load balancer, we use the fly-replay header
+    to repeat the request again on the right instance.
+
+    This only works if the correct instance is provided as a query_string parameter.
+    """
+
+    def __init__(self, app: ASGIApp) -> None:
+        self.app = app
+
+    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
+        query_string = scope.get('query_string', b'').decode()
+        query_params = parse_qs(query_string)
+        target_instance = query_params.get('fly_instance_id', [fly_instance_id])[0]
+
+        async def send_wrapper(message):
+            if target_instance != fly_instance_id:
+                if message['type'] == 'websocket.close':
+                    # fly.io only seems to look at the fly-replay header if websocket is accepted
+                    message = {'type': 'websocket.accept'}
+                if 'headers' not in message:
+                    message['headers'] = []
+                message['headers'].append([b'fly-replay', f'instance={target_instance}'.encode()])
+            await send(message)
+        await self.app(scope, receive, send_wrapper)
+
+
+app.add_middleware(FlyReplayMiddleware)
 
 
 def add_head_html() -> None:
@@ -310,6 +344,13 @@ async def index_page(client: Client) -> None:
             example_link('Pandas DataFrame', 'displays an editable [pandas](https://pandas.pydata.org) DataFrame')
             example_link('Lightbox', 'A thumbnail gallery where each image can be clicked to enlarge')
             example_link('ROS2', 'Using NiceGUI as web interface for a ROS2 robot')
+            example_link('Docker Image',
+                         'Demonstrate using the official '
+                         '[zauberzeug/nicegui](https://hub.docker.com/r/zauberzeug/nicegui) docker image')
+            example_link('Download Text as File', 'providing in-memory data like strings as file download')
+            example_link('Generate PDF', 'create SVG preview and PDF download from input form elements')
+            example_link('Custom Binding', 'create a custom binding for a label with a bindable background color')
+            example_link('Descope Auth', 'login form and user profile using [Descope](https://descope.com)')
 
     with ui.row().classes('dark-box min-h-screen mt-16'):
         link_target('why')
@@ -362,8 +403,8 @@ def documentation_page() -> None:
 
 @ui.page('/documentation/{name}')
 async def documentation_page_more(name: str, client: Client) -> None:
-    if name == 'ag_grid':
-        name = 'aggrid'  # NOTE: "AG Grid" leads to anchor name "ag_grid", but class is `ui.aggrid`
+    if name in {'ag_grid', 'e_chart'}:
+        name = name.replace('_', '')  # NOTE: "AG Grid" leads to anchor name "ag_grid", but class is `ui.aggrid`
     module = importlib.import_module(f'website.more_documentation.{name}_documentation')
     more = getattr(module, 'more', None)
     if hasattr(ui, name):
@@ -392,4 +433,4 @@ async def documentation_page_more(name: str, client: Client) -> None:
     await client.connected()
     await ui.run_javascript(f'document.title = "{name} • NiceGUI";', respond=False)
 
-ui.run(uvicorn_reload_includes='*.py, *.css, *.html')
+ui.run(uvicorn_reload_includes='*.py, *.css, *.html', reconnect_timeout=3.0)

+ 7 - 4
nicegui.code-workspace

@@ -11,13 +11,16 @@
   },
   "extensions": {
     "recommendations": [
-      "ms-python.vscode-pylance",
-      "ms-python.python",
+      "cschleiden.vscode-github-actions",
       "esbenp.prettier-vscode",
       "littlefoxteam.vscode-python-test-adapter",
-      "cschleiden.vscode-github-actions",
-      "samuelcolvin.jinjahtml",
+      "ms-python.autopep8",
       "ms-python.isort",
+      "ms-python.mypy-type-checker",
+      "ms-python.pylint",
+      "ms-python.python",
+      "ms-python.vscode-pylance",
+      "samuelcolvin.jinjahtml",
       "Vue.volar"
     ]
   },

+ 6 - 5
nicegui/__init__.py

@@ -1,18 +1,19 @@
-import importlib.metadata
-
-__version__: str = importlib.metadata.version('nicegui')
-
-from . import elements, globals, ui
+from . import ui  # pylint: disable=redefined-builtin
+from . import elements, globals  # pylint: disable=redefined-builtin
+from . import run_executor as run
 from .api_router import APIRouter
 from .client import Client
 from .nicegui import app
 from .tailwind import Tailwind
+from .version import __version__
 
 __all__ = [
+    'APIRouter',
     'app',
     'Client',
     'elements',
     'globals',
+    'run',
     'Tailwind',
     'ui',
     '__version__',

+ 40 - 27
nicegui/air.py

@@ -1,13 +1,13 @@
 import asyncio
 import gzip
-import logging
+import re
 from typing import Any, Dict
 
 import httpx
 import socketio
 from socketio import AsyncClient
 
-from . import globals
+from . import background_tasks, globals  # pylint: disable=redefined-builtin
 from .nicegui import handle_disconnect, handle_event, handle_handshake, handle_javascript_response
 
 RELAY_HOST = 'https://on-air.nicegui.io/'
@@ -19,6 +19,7 @@ class Air:
         self.token = token
         self.relay = AsyncClient()
         self.client = httpx.AsyncClient(app=globals.app)
+        self.connecting = False
 
         @self.relay.on('http')
         async def on_http(data: Dict[str, Any]) -> Dict[str, Any]:
@@ -33,22 +34,26 @@ class Air:
                 content=data['body'],
             )
             response = await self.client.send(request)
+            instance_id = data['instance-id']
             content = response.content.replace(
                 b'const extraHeaders = {};',
-                (f'const extraHeaders = {{ "fly-force-instance-id" : "{data["instance-id"]}" }};').encode(),
+                (f'const extraHeaders = {{ "fly-force-instance-id" : "{instance_id}" }};').encode(),
             )
-            response_headers = dict(response.headers)
-            response_headers['content-encoding'] = 'gzip'
+            match = re.search(b'const query = ({.*?})', content)
+            if match:
+                new_js_object = match.group(1).decode().rstrip('}') + ", 'fly_instance_id' : '" + instance_id + "'}"
+                content = content.replace(match.group(0), f'const query = {new_js_object}'.encode())
             compressed = gzip.compress(content)
-            response_headers['content-length'] = str(len(compressed))
+            response.headers.update({'content-encoding': 'gzip', 'content-length': str(len(compressed))})
             return {
                 'status_code': response.status_code,
-                'headers': response_headers,
+                'headers': response.headers.multi_items(),
                 'content': compressed,
             }
 
         @self.relay.on('ready')
         def on_ready(data: Dict[str, Any]) -> None:
+            globals.app.urls.add(data['device_url'])
             print(f'NiceGUI is on air at {data["device_url"]}', flush=True)
 
         @self.relay.on('error')
@@ -72,7 +77,7 @@ class Air:
             if client_id not in globals.clients:
                 return
             client = globals.clients[client_id]
-            handle_disconnect(client)
+            client.disconnect_task = background_tasks.create(handle_disconnect(client))
 
         @self.relay.on('event')
         def on_event(data: Dict[str, Any]) -> None:
@@ -80,7 +85,7 @@ class Air:
             if client_id not in globals.clients:
                 return
             client = globals.clients[client_id]
-            if isinstance(data['msg']['args'], list) and 'socket_id' in data['msg']['args']:
+            if isinstance(data['msg']['args'], dict) and 'socket_id' in data['msg']['args']:
                 data['msg']['args']['socket_id'] = client_id  # HACK: translate socket_id of ui.scene's init event
             handle_event(client, data['msg'])
 
@@ -98,30 +103,38 @@ class Air:
             await self.connect()
 
         @self.relay.on('reconnect')
-        async def on_reconnect(data: Dict[str, Any]) -> None:
+        async def on_reconnect(_: Dict[str, Any]) -> None:
             await self.connect()
 
     async def connect(self) -> None:
-        try:
-            if self.relay.connected:
+        if self.connecting:
+            return
+        self.connecting = True
+        backoff_time = 1
+        while True:
+            try:
+                if self.relay.connected:
+                    await self.relay.disconnect()
+                await self.relay.connect(
+                    f'{RELAY_HOST}?device_token={self.token}',
+                    socketio_path='/on_air/socket.io',
+                    transports=['websocket', 'polling'],  # favor websocket over polling
+                )
+                break
+            except socketio.exceptions.ConnectionError:
+                pass
+            except ValueError:  # NOTE this sometimes happens when the internal socketio client is not yet ready
                 await self.relay.disconnect()
-            await self.relay.connect(
-                f'{RELAY_HOST}?device_token={self.token}',
-                socketio_path='/on_air/socket.io',
-                transports=['websocket', 'polling'],
-            )
-        except socketio.exceptions.ConnectionError:
-            await self.connect()
-        except ValueError:  # NOTE this sometimes happens when the internal socketio client is not yet ready
-            await self.relay.disconnect()
-            await self.connect()
-        except Exception:
-            logging.exception('Could not connect to NiceGUI On Air server.')
-            print('Could not connect to NiceGUI On Air server.', flush=True)
-            await self.connect()
+            except Exception:
+                globals.log.exception('Could not connect to NiceGUI On Air server.')
+
+            await asyncio.sleep(backoff_time)
+            backoff_time = min(backoff_time * 2, 32)
+        self.connecting = False
 
     async def disconnect(self) -> None:
         await self.relay.disconnect()
 
     async def emit(self, message_type: str, data: Dict[str, Any], room: str) -> None:
-        await self.relay.emit('forward', {'event': message_type, 'data': data, 'room': room})
+        if self.relay.connected:
+            await self.relay.emit('forward', {'event': message_type, 'data': data, 'room': room})

+ 1 - 1
nicegui/api_router.py

@@ -13,7 +13,7 @@ class APIRouter(fastapi.APIRouter):
              title: Optional[str] = None,
              viewport: Optional[str] = None,
              favicon: Optional[Union[str, Path]] = None,
-             dark: Optional[bool] = ...,
+             dark: Optional[bool] = ...,  # type: ignore
              response_timeout: float = 3.0,
              **kwargs,
              ) -> Callable:

+ 35 - 18
nicegui/app.py

@@ -1,12 +1,13 @@
 from pathlib import Path
 from typing import Awaitable, Callable, Optional, Union
 
-from fastapi import FastAPI, Request
+from fastapi import FastAPI, HTTPException, Request
 from fastapi.responses import FileResponse, StreamingResponse
 from fastapi.staticfiles import StaticFiles
 
-from . import globals, helpers
+from . import globals, helpers  # pylint: disable=redefined-builtin
 from .native import Native
+from .observables import ObservableSet
 from .storage import Storage
 
 
@@ -16,6 +17,7 @@ class App(FastAPI):
         super().__init__(**kwargs)
         self.native = Native()
         self.storage = Storage()
+        self.urls = ObservableSet()
 
     def on_connect(self, handler: Union[Callable, Awaitable]) -> None:
         """Called every time a new client connects to NiceGUI.
@@ -61,8 +63,11 @@ class App(FastAPI):
         Only possible when auto-reload is disabled.
         """
         if globals.reload:
-            raise Exception('calling shutdown() is not supported when auto-reload is enabled')
-        globals.server.should_exit = True
+            raise RuntimeError('calling shutdown() is not supported when auto-reload is enabled')
+        if self.native.main_window:
+            self.native.main_window.destroy()
+        else:
+            globals.server.should_exit = True
 
     def add_static_files(self, url_path: str, local_directory: Union[str, Path]) -> None:
         """Add a directory of static files.
@@ -82,7 +87,11 @@ class App(FastAPI):
             raise ValueError('''Path cannot be "/", because it would hide NiceGUI's internal "/_nicegui" route.''')
         globals.app.mount(url_path, StaticFiles(directory=str(local_directory)))
 
-    def add_static_file(self, *, local_file: Union[str, Path], url_path: Optional[str] = None) -> str:
+    def add_static_file(self, *,
+                        local_file: Union[str, Path],
+                        url_path: Optional[str] = None,
+                        single_use: bool = False,
+                        ) -> str:
         """Add a single static file.
 
         Allows a local file to be accessed online with enabled caching.
@@ -93,19 +102,21 @@ class App(FastAPI):
 
         :param local_file: local file to serve as static content
         :param url_path: string that starts with a slash "/" and identifies the path at which the file should be served (default: None -> auto-generated URL path)
+        :param single_use: whether to remove the route after the file has been downloaded once (default: False)
         :return: URL path which can be used to access the file
         """
         file = Path(local_file).resolve()
         if not file.is_file():
             raise ValueError(f'File not found: {file}')
-        if url_path is None:
-            url_path = f'/_nicegui/auto/static/{helpers.hash_file_path(file)}/{file.name}'
+        path = f'/_nicegui/auto/static/{helpers.hash_file_path(file)}/{file.name}' if url_path is None else url_path
 
-        @self.get(url_path)
-        async def read_item() -> FileResponse:
+        @self.get(path)
+        def read_item() -> FileResponse:
+            if single_use:
+                self.remove_route(path)
             return FileResponse(file, headers={'Cache-Control': 'public, max-age=3600'})
 
-        return url_path
+        return path
 
     def add_media_files(self, url_path: str, local_directory: Union[str, Path]) -> None:
         """Add directory of media files.
@@ -122,13 +133,17 @@ class App(FastAPI):
         :param local_directory: local folder with files to serve as media content
         """
         @self.get(url_path + '/{filename:path}')
-        async def read_item(request: Request, filename: str) -> StreamingResponse:
+        def read_item(request: Request, filename: str) -> StreamingResponse:
             filepath = Path(local_directory) / filename
             if not filepath.is_file():
-                return {'detail': 'Not Found'}, 404
+                raise HTTPException(status_code=404, detail='Not Found')
             return helpers.get_streaming_response(filepath, request)
 
-    def add_media_file(self, *, local_file: Union[str, Path], url_path: Optional[str] = None) -> str:
+    def add_media_file(self, *,
+                       local_file: Union[str, Path],
+                       url_path: Optional[str] = None,
+                       single_use: bool = False,
+                       ) -> str:
         """Add a single media file.
 
         Allows a local file to be streamed.
@@ -139,19 +154,21 @@ class App(FastAPI):
 
         :param local_file: local file to serve as media content
         :param url_path: string that starts with a slash "/" and identifies the path at which the file should be served (default: None -> auto-generated URL path)
+        :param single_use: whether to remove the route after the media file has been downloaded once (default: False)
         :return: URL path which can be used to access the file
         """
         file = Path(local_file).resolve()
         if not file.is_file():
             raise ValueError(f'File not found: {local_file}')
-        if url_path is None:
-            url_path = f'/_nicegui/auto/media/{helpers.hash_file_path(file)}/{file.name}'
+        path = f'/_nicegui/auto/media/{helpers.hash_file_path(file)}/{file.name}' if url_path is None else url_path
 
-        @self.get(url_path)
-        async def read_item(request: Request) -> StreamingResponse:
+        @self.get(path)
+        def read_item(request: Request) -> StreamingResponse:
+            if single_use:
+                self.remove_route(path)
             return helpers.get_streaming_response(file, request)
 
-        return url_path
+        return path
 
     def remove_route(self, path: str) -> None:
         """Remove routes with the given path."""

+ 8 - 7
nicegui/background_tasks.py

@@ -1,20 +1,20 @@
 """inspired from https://quantlane.com/blog/ensure-asyncio-task-exceptions-get-logged/"""
+from __future__ import annotations
+
 import asyncio
 import sys
-from typing import Awaitable, Dict, Set, TypeVar
-
-from . import globals
+from typing import Awaitable, Dict, Set
 
-T = TypeVar('T')
+from . import globals  # pylint: disable=redefined-builtin,cyclic-import
 
 name_supported = sys.version_info[1] >= 8
 
 running_tasks: Set[asyncio.Task] = set()
 lazy_tasks_running: Dict[str, asyncio.Task] = {}
-lazy_tasks_waiting: Dict[str, Awaitable[T]] = {}
+lazy_tasks_waiting: Dict[str, Awaitable] = {}
 
 
-def create(coroutine: Awaitable[T], *, name: str = 'unnamed task') -> 'asyncio.Task[T]':
+def create(coroutine: Awaitable, *, name: str = 'unnamed task') -> asyncio.Task:
     """Wraps a loop.create_task call and ensures there is an exception handler added to the task.
 
     If the task raises an exception, it is logged and handled by the global exception handlers.
@@ -22,6 +22,7 @@ def create(coroutine: Awaitable[T], *, name: str = 'unnamed task') -> 'asyncio.T
     See https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task.
     """
     assert globals.loop is not None
+    assert asyncio.iscoroutine(coroutine)
     task: asyncio.Task = \
         globals.loop.create_task(coroutine, name=name) if name_supported else globals.loop.create_task(coroutine)
     task.add_done_callback(_handle_task_result)
@@ -30,7 +31,7 @@ def create(coroutine: Awaitable[T], *, name: str = 'unnamed task') -> 'asyncio.T
     return task
 
 
-def create_lazy(coroutine: Awaitable[T], *, name: str) -> None:
+def create_lazy(coroutine: Awaitable, *, name: str) -> None:
     """Wraps a create call and ensures a second task with the same name is delayed until the first one is done.
 
     If a third task with the same name is created while the first one is still running, the second one is discarded.

+ 44 - 30
nicegui/binding.py

@@ -1,11 +1,12 @@
 import asyncio
-import logging
 import time
 from collections import defaultdict
 from collections.abc import Mapping
-from typing import Any, Callable, DefaultDict, Dict, List, Optional, Set, Tuple, Type, Union
+from typing import Any, Callable, DefaultDict, Dict, Iterable, List, Optional, Set, Tuple, Type, Union
 
-from . import globals
+from . import globals  # pylint: disable=redefined-builtin
+
+MAX_PROPAGATION_TIME = 0.01
 
 bindings: DefaultDict[Tuple[int, str], List] = defaultdict(list)
 bindable_properties: Dict[Tuple[int, str], Any] = {}
@@ -15,15 +16,13 @@ active_links: List[Tuple[Any, str, Any, str, Callable[[Any], Any]]] = []
 def has_attribute(obj: Union[object, Mapping], name: str) -> Any:
     if isinstance(obj, Mapping):
         return name in obj
-    else:
-        return hasattr(obj, name)
+    return hasattr(obj, name)
 
 
 def get_attribute(obj: Union[object, Mapping], name: str) -> Any:
     if isinstance(obj, Mapping):
         return obj[name]
-    else:
-        return getattr(obj, name)
+    return getattr(obj, name)
 
 
 def set_attribute(obj: Union[object, Mapping], name: str, value: Any) -> None:
@@ -33,23 +32,28 @@ def set_attribute(obj: Union[object, Mapping], name: str, value: Any) -> None:
         setattr(obj, name, value)
 
 
-async def loop() -> None:
+async def refresh_loop() -> None:
     while True:
-        visited: Set[Tuple[int, str]] = set()
-        t = time.time()
-        for link in active_links:
-            (source_obj, source_name, target_obj, target_name, transform) = link
-            if has_attribute(source_obj, source_name):
-                value = transform(get_attribute(source_obj, source_name))
-                if not has_attribute(target_obj, target_name) or get_attribute(target_obj, target_name) != value:
-                    set_attribute(target_obj, target_name, value)
-                    propagate(target_obj, target_name, visited)
-            del link, source_obj, target_obj
-        if time.time() - t > 0.01:
-            logging.warning(f'binding propagation for {len(active_links)} active links took {time.time() - t:.3f} s')
+        _refresh_step()
         await asyncio.sleep(globals.binding_refresh_interval)
 
 
+def _refresh_step():
+    visited: Set[Tuple[int, str]] = set()
+    t = time.time()
+    for link in active_links:
+        (source_obj, source_name, target_obj, target_name, transform) = link
+        if has_attribute(source_obj, source_name):
+            value = transform(get_attribute(source_obj, source_name))
+            if not has_attribute(target_obj, target_name) or get_attribute(target_obj, target_name) != value:
+                set_attribute(target_obj, target_name, value)
+                propagate(target_obj, target_name, visited)
+        del link, source_obj, target_obj  # pylint: disable=modified-iterating-list
+    if time.time() - t > MAX_PROPAGATION_TIME:
+        globals.log.warning(
+            f'binding propagation for {len(active_links)} active links took {time.time() - t:.3f} s')
+
+
 def propagate(source_obj: Any, source_name: str, visited: Optional[Set[Tuple[int, str]]] = None) -> None:
     if visited is None:
         visited = set()
@@ -90,15 +94,15 @@ class BindableProperty:
         self.on_change = on_change
 
     def __set_name__(self, _, name: str) -> None:
-        self.name = name
+        self.name = name  # pylint: disable=attribute-defined-outside-init
 
     def __get__(self, owner: Any, _=None) -> Any:
         return getattr(owner, '___' + self.name)
 
     def __set__(self, owner: Any, value: Any) -> None:
-        has_attribute = hasattr(owner, '___' + self.name)
-        value_changed = has_attribute and getattr(owner, '___' + self.name) != value
-        if has_attribute and not value_changed:
+        has_attr = hasattr(owner, '___' + self.name)
+        value_changed = has_attr and getattr(owner, '___' + self.name) != value
+        if has_attr and not value_changed:
             return
         setattr(owner, '___' + self.name, value)
         bindable_properties[(id(owner), self.name)] = owner
@@ -107,22 +111,32 @@ class BindableProperty:
             self.on_change(owner, value)
 
 
-def remove(objects: List[Any], type: Type) -> None:
+def remove(objects: Iterable[Any], type_: Type) -> None:
     active_links[:] = [
         (source_obj, source_name, target_obj, target_name, transform)
         for source_obj, source_name, target_obj, target_name, transform in active_links
-        if not (isinstance(source_obj, type) and source_obj in objects or
-                isinstance(target_obj, type) and target_obj in objects)
+        if not (isinstance(source_obj, type_) and source_obj in objects or
+                isinstance(target_obj, type_) and target_obj in objects)
     ]
     for key, binding_list in list(bindings.items()):
         binding_list[:] = [
             (source_obj, target_obj, target_name, transform)
             for source_obj, target_obj, target_name, transform in binding_list
-            if not (isinstance(source_obj, type) and source_obj in objects or
-                    isinstance(target_obj, type) and target_obj in objects)
+            if not (isinstance(source_obj, type_) and source_obj in objects or
+                    isinstance(target_obj, type_) and target_obj in objects)
         ]
         if not binding_list:
             del bindings[key]
     for (obj_id, name), obj in list(bindable_properties.items()):
-        if isinstance(obj, type) and obj in objects:
+        if isinstance(obj, type_) and obj in objects:
             del bindable_properties[(obj_id, name)]
+
+
+def reset() -> None:
+    """Clear all bindings.
+
+    This function is intended for testing purposes only.
+    """
+    bindings.clear()
+    bindable_properties.clear()
+    active_links.clear()

+ 39 - 11
nicegui/client.py

@@ -1,8 +1,10 @@
+from __future__ import annotations
+
 import asyncio
 import time
 import uuid
 from pathlib import Path
-from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Optional, Union
+from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterable, List, Optional, Union
 
 from fastapi import Request
 from fastapi.responses import Response
@@ -10,10 +12,11 @@ from fastapi.templating import Jinja2Templates
 
 from nicegui import json
 
-from . import __version__, globals, outbox
+from . import binding, globals, outbox  # pylint: disable=redefined-builtin
 from .dependencies import generate_resources
 from .element import Element
 from .favicon import get_favicon_url
+from .version import __version__
 
 if TYPE_CHECKING:
     from .page import page
@@ -23,7 +26,7 @@ templates = Jinja2Templates(Path(__file__).parent / 'templates')
 
 class Client:
 
-    def __init__(self, page: 'page', *, shared: bool = False) -> None:
+    def __init__(self, page: page, *, shared: bool = False) -> None:
         self.id = str(uuid.uuid4())
         self.created = time.time()
         globals.clients[self.id] = self
@@ -35,6 +38,7 @@ class Client:
         self.environ: Optional[Dict[str, Any]] = None
         self.shared = shared
         self.on_air = False
+        self.disconnect_task: Optional[asyncio.Task] = None
 
         with Element('q-layout', _client=self).props('view="hhh lpr fff"').classes('nicegui-layout') as self.layout:
             with Element('q-page-container') as self.page_container:
@@ -54,7 +58,7 @@ class Client:
     @property
     def ip(self) -> Optional[str]:
         """Return the IP address of the client, or None if the client is not connected."""
-        return self.environ['asgi.scope']['client'][0] if self.environ else None
+        return self.environ['asgi.scope']['client'][0] if self.environ else None  # pylint: disable=unsubscriptable-object
 
     @property
     def has_socket_connection(self) -> bool:
@@ -70,18 +74,24 @@ class Client:
 
     def build_response(self, request: Request, status_code: int = 200) -> Response:
         prefix = request.headers.get('X-Forwarded-Prefix', request.scope.get('root_path', ''))
-        elements = json.dumps({id: element._to_dict() for id, element in self.elements.items()})
+        elements = json.dumps({
+            id: element._to_dict() for id, element in self.elements.items()  # pylint: disable=protected-access
+        })
+        socket_io_js_query_params = {**globals.socket_io_js_query_params, 'client_id': self.id}
         vue_html, vue_styles, vue_scripts, imports, js_imports = generate_resources(prefix, self.elements.values())
         return templates.TemplateResponse('index.html', {
             'request': request,
             'version': __version__,
-            'client_id': str(self.id),
-            'elements': elements,
+            'elements': elements.replace('&', '&amp;')
+                                .replace('<', '&lt;')
+                                .replace('>', '&gt;')
+                                .replace('`', '&#96;'),
             'head_html': self.head_html,
             'body_html': '<style>' + '\n'.join(vue_styles) + '</style>\n' + self.body_html + '\n' + '\n'.join(vue_html),
             'vue_scripts': '\n'.join(vue_scripts),
             'imports': json.dumps(imports),
             'js_imports': '\n'.join(js_imports),
+            'quasar_config': json.dumps(globals.quasar_config),
             'title': self.page.resolve_title(),
             'viewport': self.page.resolve_viewport(),
             'favicon_url': get_favicon_url(self.page, prefix),
@@ -89,14 +99,17 @@ class Client:
             'language': self.page.resolve_language(),
             'prefix': prefix,
             'tailwind': globals.tailwind,
+            'prod_js': globals.prod_js,
+            'socket_io_js_query_params': socket_io_js_query_params,
             'socket_io_js_extra_headers': globals.socket_io_js_extra_headers,
+            'socket_io_js_transports': globals.socket_io_js_transports,
         }, status_code, {'Cache-Control': 'no-store', 'X-NiceGUI-Content': 'page'})
 
     async def connected(self, timeout: float = 3.0, check_interval: float = 0.1) -> None:
         """Block execution until the client is connected."""
         self.is_waiting_for_connection = True
         deadline = time.time() + timeout
-        while not self.environ:
+        while not self.has_socket_connection:
             if time.time() > deadline:
                 raise TimeoutError(f'No connection after {timeout} seconds')
             await asyncio.sleep(check_interval)
@@ -104,7 +117,7 @@ class Client:
 
     async def disconnected(self, check_interval: float = 0.1) -> None:
         """Block execution until the client disconnects."""
-        if not self.environ:
+        if not self.has_socket_connection:
             await self.connected()
         self.is_waiting_for_disconnect = True
         while self.id in globals.clients:
@@ -134,10 +147,10 @@ class Client:
             await asyncio.sleep(check_interval)
         return self.waiting_javascript_commands.pop(request_id)
 
-    def open(self, target: Union[Callable[..., Any], str]) -> None:
+    def open(self, target: Union[Callable[..., Any], str], new_tab: bool = False) -> None:
         """Open a new page in the client."""
         path = target if isinstance(target, str) else globals.page_routes[target]
-        outbox.enqueue_message('open', path, self.id)
+        outbox.enqueue_message('open', {'path': path, 'new_tab': new_tab}, self.id)
 
     def download(self, url: str, filename: Optional[str] = None) -> None:
         """Download a file from the given URL."""
@@ -150,3 +163,18 @@ class Client:
     def on_disconnect(self, handler: Union[Callable[..., Any], Awaitable]) -> None:
         """Register a callback to be called when the client disconnects."""
         self.disconnect_handlers.append(handler)
+
+    def remove_elements(self, elements: Iterable[Element]) -> None:
+        """Remove the given elements from the client."""
+        binding.remove(elements, Element)
+        element_ids = [element.id for element in elements]
+        for element_id in element_ids:
+            del self.elements[element_id]
+        for element in elements:
+            element._on_delete()  # pylint: disable=protected-access
+            element._deleted = True  # pylint: disable=protected-access
+            outbox.enqueue_delete(element)
+
+    def remove_all_elements(self) -> None:
+        """Remove all elements from the client."""
+        self.remove_elements(self.elements.values())

+ 3 - 0
nicegui/dataclasses.py

@@ -0,0 +1,3 @@
+import sys
+
+KWONLY_SLOTS = {'kw_only': True, 'slots': True} if sys.version_info >= (3, 10) else {}

+ 20 - 19
nicegui/dependencies.py

@@ -2,12 +2,13 @@ from __future__ import annotations
 
 from dataclasses import dataclass
 from pathlib import Path
-from typing import TYPE_CHECKING, Dict, List, Set, Tuple
+from typing import TYPE_CHECKING, Dict, Iterable, List, Set, Tuple
 
 import vbuild
 
-from . import __version__
-from .helpers import KWONLY_SLOTS, hash_file_path
+from .dataclasses import KWONLY_SLOTS
+from .helpers import hash_file_path
+from .version import __version__
 
 if TYPE_CHECKING:
     from .element import Element
@@ -104,11 +105,11 @@ def get_name(path: Path) -> str:
     return path.name.split('.', 1)[0]
 
 
-def generate_resources(prefix: str, elements: List[Element]) -> Tuple[List[str],
-                                                                      List[str],
-                                                                      List[str],
-                                                                      Dict[str, str],
-                                                                      List[str]]:
+def generate_resources(prefix: str, elements: Iterable[Element]) -> Tuple[List[str],
+                                                                          List[str],
+                                                                          List[str],
+                                                                          Dict[str, str],
+                                                                          List[str]]:
     done_libraries: Set[str] = set()
     done_components: Set[str] = set()
     vue_scripts: List[str] = []
@@ -124,12 +125,12 @@ def generate_resources(prefix: str, elements: List[Element]) -> Tuple[List[str],
             done_libraries.add(key)
 
     # build the none-optimized component (i.e. the Vue component)
-    for key, component in vue_components.items():
+    for key, vue_component in vue_components.items():
         if key not in done_components:
-            vue_html.append(component.html)
-            vue_scripts.append(component.script.replace(f"Vue.component('{component.name}',",
-                                                        f"app.component('{component.tag}',", 1))
-            vue_styles.append(component.style)
+            vue_html.append(vue_component.html)
+            vue_scripts.append(vue_component.script.replace(f"Vue.component('{vue_component.name}',",
+                                                            f"app.component('{vue_component.tag}',", 1))
+            vue_styles.append(vue_component.style)
             done_components.add(key)
 
     # build the resources associated with the elements
@@ -141,10 +142,10 @@ def generate_resources(prefix: str, elements: List[Element]) -> Tuple[List[str],
                     js_imports.append(f'import "{url}";')
                 done_libraries.add(library.key)
         if element.component:
-            component = element.component
-            if component.key not in done_components and component.path.suffix.lower() == '.js':
-                url = f'{prefix}/_nicegui/{__version__}/components/{component.key}'
-                js_imports.append(f'import {{ default as {component.name} }} from "{url}";')
-                js_imports.append(f'app.component("{component.tag}", {component.name});')
-                done_components.add(component.key)
+            js_component = element.component
+            if js_component.key not in done_components and js_component.path.suffix.lower() == '.js':
+                url = f'{prefix}/_nicegui/{__version__}/components/{js_component.key}'
+                js_imports.append(f'import {{ default as {js_component.name} }} from "{url}";')
+                js_imports.append(f'app.component("{js_component.tag}", {js_component.name});')
+                done_components.add(js_component.key)
     return vue_html, vue_styles, vue_scripts, imports, js_imports

+ 0 - 13
nicegui/deprecation.py

@@ -1,13 +0,0 @@
-import warnings
-from functools import wraps
-
-warnings.simplefilter('always', DeprecationWarning)
-
-
-def deprecated(func: type, old_name: str, new_name: str, issue: int) -> type:
-    @wraps(func)
-    def wrapped(*args, **kwargs):
-        url = f'https://github.com/zauberzeug/nicegui/issues/{issue}'
-        warnings.warn(DeprecationWarning(f'{old_name} is deprecated, use {new_name} instead ({url})'))
-        return func(*args, **kwargs)
-    return wrapped

+ 105 - 25
nicegui/element.py

@@ -4,14 +4,14 @@ import inspect
 import re
 from copy import copy, deepcopy
 from pathlib import Path
-from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Union
+from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Sequence, Union
 
 from typing_extensions import Self
 
 from nicegui import json
 
-from . import binding, events, globals, outbox, storage
-from .dependencies import JsComponent, Library, register_library, register_vue_component
+from . import events, globals, outbox, storage  # pylint: disable=redefined-builtin
+from .dependencies import Component, Library, register_library, register_vue_component
 from .elements.mixins.visibility import Visibility
 from .event_listener import EventListener
 from .slot import Slot
@@ -24,10 +24,13 @@ PROPS_PATTERN = re.compile(r'([:\w\-]+)(?:=(?:("[^"\\]*(?:\\.[^"\\]*)*")|([\w\-.
 
 
 class Element(Visibility):
-    component: Optional[JsComponent] = None
+    component: Optional[Component] = None
     libraries: List[Library] = []
     extra_libraries: List[Library] = []
     exposed_libraries: List[Library] = []
+    _default_props: Dict[str, Any] = {}
+    _default_classes: List[str] = []
+    _default_style: Dict[str, str] = {}
 
     def __init__(self, tag: Optional[str] = None, *, _client: Optional[Client] = None) -> None:
         """Generic Element
@@ -44,12 +47,16 @@ class Element(Visibility):
         self.client.next_element_id += 1
         self.tag = tag if tag else self.component.tag if self.component else 'div'
         self._classes: List[str] = []
+        self._classes.extend(self._default_classes)
         self._style: Dict[str, str] = {}
+        self._style.update(self._default_style)
         self._props: Dict[str, Any] = {'key': self.id}  # HACK: workaround for #600 and #898
+        self._props.update(self._default_props)
         self._event_listeners: Dict[str, EventListener] = {}
         self._text: Optional[str] = None
         self.slots: Dict[str, Slot] = {}
         self.default_slot = self.add_slot('default')
+        self._deleted: bool = False
 
         self.client.elements[self.id] = self
         self.parent_slot: Optional[Slot] = None
@@ -96,6 +103,10 @@ class Element(Visibility):
             for path in glob_absolute_paths(library):
                 cls.exposed_libraries.append(register_library(path, expose=True))
 
+        cls._default_props = copy(cls._default_props)
+        cls._default_classes = copy(cls._default_classes)
+        cls._default_style = copy(cls._default_style)
+
     def add_slot(self, name: str, template: Optional[str] = None) -> Slot:
         """Add a slot to the element.
 
@@ -175,6 +186,24 @@ class Element(Visibility):
             self.update()
         return self
 
+    @classmethod
+    def default_classes(cls, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) \
+            -> Self:
+        """Apply, remove, or replace default HTML classes.
+
+        This allows modifying the look of the element or its layout using `Tailwind <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ classes.
+
+        Removing or replacing classes can be helpful if predefined classes are not desired.
+        All elements of this class will share these HTML classes.
+        These must be defined before element instantiation.
+
+        :param add: whitespace-delimited string of classes
+        :param remove: whitespace-delimited string of classes to remove from the element
+        :param replace: whitespace-delimited string of classes to use instead of existing ones
+        """
+        cls._default_classes = cls._update_classes_list(cls._default_classes, add, remove, replace)
+        return cls
+
     @staticmethod
     def _parse_style(text: Optional[str]) -> Dict[str, str]:
         result = {}
@@ -204,6 +233,26 @@ class Element(Visibility):
             self.update()
         return self
 
+    @classmethod
+    def default_style(cls, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) -> Self:
+        """Apply, remove, or replace default CSS definitions.
+
+        Removing or replacing styles can be helpful if the predefined style is not desired.
+        All elements of this class will share these CSS definitions.
+        These must be defined before element instantiation.
+
+        :param add: semicolon-separated list of styles to add to the element
+        :param remove: semicolon-separated list of styles to remove from the element
+        :param replace: semicolon-separated list of styles to use instead of existing ones
+        """
+        if replace is not None:
+            cls._default_style.clear()
+        for key in cls._parse_style(remove):
+            cls._default_style.pop(key, None)
+        cls._default_style.update(cls._parse_style(add))
+        cls._default_style.update(cls._parse_style(replace))
+        return cls
+
     @staticmethod
     def _parse_props(text: Optional[str]) -> Dict[str, Any]:
         dictionary = {}
@@ -239,6 +288,27 @@ class Element(Visibility):
             self.update()
         return self
 
+    @classmethod
+    def default_props(cls, add: Optional[str] = None, *, remove: Optional[str] = None) -> Self:
+        """Add or remove default props.
+
+        This allows modifying the look of the element or its layout using `Quasar <https://quasar.dev/>`_ props.
+        Since props are simply applied as HTML attributes, they can be used with any HTML element.
+        All elements of this class will share these props.
+        These must be defined before element instantiation.
+
+        Boolean properties are assumed ``True`` if no value is specified.
+
+        :param add: whitespace-delimited list of either boolean values or key=value pair to add
+        :param remove: whitespace-delimited list of property keys to remove
+        """
+        for key in cls._parse_props(remove):
+            if key in cls._default_props:
+                del cls._default_props[key]
+        for key, value in cls._parse_props(add).items():
+            cls._default_props[key] = value
+        return cls
+
     def tooltip(self, text: str) -> Self:
         """Add a tooltip to the element.
 
@@ -246,13 +316,13 @@ class Element(Visibility):
         """
         with self:
             tooltip = Element('q-tooltip')
-            tooltip._text = text
+            tooltip._text = text  # pylint: disable=protected-access
         return self
 
     def on(self,
-           type: str,
+           type: str,  # pylint: disable=redefined-builtin
            handler: Optional[Callable[..., Any]] = None,
-           args: Optional[List[str]] = None, *,
+           args: Union[None, Sequence[str], Sequence[Optional[Sequence[str]]]] = None, *,
            throttle: float = 0.0,
            leading_events: bool = True,
            trailing_events: bool = True,
@@ -270,7 +340,7 @@ class Element(Visibility):
             listener = EventListener(
                 element_id=self.id,
                 type=type,
-                args=[args] if args and isinstance(args[0], str) else args,
+                args=[args] if args and isinstance(args[0], str) else args,  # type: ignore
                 handler=handler,
                 throttle=throttle,
                 leading_events=leading_events,
@@ -300,21 +370,19 @@ class Element(Visibility):
         if not globals.loop:
             return
         data = {'id': self.id, 'name': name, 'args': args}
-        outbox.enqueue_message('run_method', data, globals._socket_id or self.client.id)
+        target_id = globals._socket_id or self.client.id  # pylint: disable=protected-access
+        outbox.enqueue_message('run_method', data, target_id)
 
-    def _collect_descendant_ids(self) -> List[int]:
-        ids: List[int] = [self.id]
+    def _collect_descendants(self, *, include_self: bool = False) -> List[Element]:
+        elements: List[Element] = [self] if include_self else []
         for child in self:
-            ids.extend(child._collect_descendant_ids())
-        return ids
+            elements.extend(child._collect_descendants(include_self=True))  # pylint: disable=protected-access
+        return elements
 
     def clear(self) -> None:
         """Remove all child elements."""
-        descendants = [self.client.elements[id] for id in self._collect_descendant_ids()[1:]]
-        binding.remove(descendants, Element)
-        for element in descendants:
-            element.delete()
-            del self.client.elements[element.id]
+        descendants = self._collect_descendants()
+        self.client.remove_elements(descendants)
         for slot in self.slots.values():
             slot.children.clear()
         self.update()
@@ -342,13 +410,25 @@ class Element(Visibility):
         if isinstance(element, int):
             children = list(self)
             element = children[element]
-        binding.remove([element], Element)
-        element.delete()
-        del self.client.elements[element.id]
-        for slot in self.slots.values():
-            slot.children[:] = [e for e in slot if e.id != element.id]
+        elements = element._collect_descendants(include_self=True)  # pylint: disable=protected-access
+        self.client.remove_elements(elements)
+        assert element.parent_slot is not None
+        element.parent_slot.children.remove(element)
         self.update()
 
     def delete(self) -> None:
-        """Perform cleanup when the element is deleted."""
-        outbox.enqueue_delete(self)
+        """Delete the element."""
+        self.client.remove_elements([self])
+        assert self.parent_slot is not None
+        self.parent_slot.children.remove(self)
+
+    def _on_delete(self) -> None:
+        """Called when the element is deleted.
+
+        This method can be overridden in subclasses to perform cleanup tasks.
+        """
+
+    @property
+    def is_deleted(self) -> bool:
+        """Whether the element has been deleted."""
+        return self._deleted

+ 0 - 0
nicegui/elements/__init__.py


+ 8 - 1
nicegui/elements/aggrid.js

@@ -1,3 +1,5 @@
+import { convertDynamicProperties } from "../../static/utils/dynamic_properties.js";
+
 export default {
   template: "<div></div>",
   mounted() {
@@ -8,13 +10,14 @@ export default {
       this.$el.textContent = "";
       this.gridOptions = {
         ...this.options,
-        onGridReady: (params) => params.api.sizeColumnsToFit(),
+        onGridReady: this.auto_size_columns ? (params) => params.api.sizeColumnsToFit() : undefined,
       };
       for (const column of this.html_columns) {
         if (this.gridOptions.columnDefs[column].cellRenderer === undefined) {
           this.gridOptions.columnDefs[column].cellRenderer = (params) => (params.value ? params.value : "");
         }
       }
+      convertDynamicProperties(this.gridOptions, true);
 
       // Code for CheckboxRenderer https://blog.ag-grid.com/binding-boolean-values-to-checkboxes-in-ag-grid/
       function CheckboxRenderer() {}
@@ -47,6 +50,9 @@ export default {
     call_api_method(name, ...args) {
       this.gridOptions.api[name](...args);
     },
+    call_column_api_method(name, ...args) {
+      this.gridOptions.columnApi[name](...args);
+    },
     handle_event(type, args) {
       this.$emit(type, {
         value: args.value,
@@ -85,5 +91,6 @@ export default {
   props: {
     options: Object,
     html_columns: Array,
+    auto_size_columns: Boolean,
   },
 };

+ 53 - 5
nicegui/elements/aggrid.py

@@ -2,13 +2,25 @@ from __future__ import annotations
 
 from typing import Dict, List, Optional, cast
 
+from .. import globals  # pylint: disable=redefined-builtin
 from ..element import Element
 from ..functions.javascript import run_javascript
 
+try:
+    import pandas as pd
+    globals.optional_features.add('pandas')
+except ImportError:
+    pass
+
 
 class AgGrid(Element, component='aggrid.js', libraries=['lib/aggrid/ag-grid-community.min.js']):
 
-    def __init__(self, options: Dict, *, html_columns: List[int] = [], theme: str = 'balham') -> None:
+    def __init__(self,
+                 options: Dict, *,
+                 html_columns: List[int] = [],
+                 theme: str = 'balham',
+                 auto_size_columns: bool = True,
+                 ) -> None:
         """AG Grid
 
         An element to create a grid using `AG Grid <https://www.ag-grid.com/>`_.
@@ -18,24 +30,50 @@ class AgGrid(Element, component='aggrid.js', libraries=['lib/aggrid/ag-grid-comm
         :param options: dictionary of AG Grid options
         :param html_columns: list of columns that should be rendered as HTML (default: `[]`)
         :param theme: AG Grid theme (default: 'balham')
+        :param auto_size_columns: whether to automatically resize columns to fit the grid width (default: `True`)
         """
         super().__init__()
         self._props['options'] = options
         self._props['html_columns'] = html_columns
+        self._props['auto_size_columns'] = auto_size_columns
         self._classes = ['nicegui-aggrid', f'ag-theme-{theme}']
 
     @staticmethod
-    def from_pandas(df: 'pandas.DataFrame', *, theme: str = 'balham') -> AgGrid:
+    def from_pandas(df: pd.DataFrame, *,
+                    theme: str = 'balham',
+                    auto_size_columns: bool = True,
+                    options: Dict = {}) -> AgGrid:
         """Create an AG Grid from a Pandas DataFrame.
 
+        Note:
+        If the DataFrame contains non-serializable columns of type `datetime64[ns]`, `timedelta64[ns]`, `complex128` or `period[M]`,
+        they will be converted to strings.
+        To use a different conversion, convert the DataFrame manually before passing it to this method.
+        See `issue 1698 <https://github.com/zauberzeug/nicegui/issues/1698>`_ for more information.
+
         :param df: Pandas DataFrame
         :param theme: AG Grid theme (default: 'balham')
-        :return: AG Grid
+        :param auto_size_columns: whether to automatically resize columns to fit the grid width (default: `True`)
+        :param options: dictionary of additional AG Grid options
+        :return: AG Grid element
         """
+        date_cols = df.columns[df.dtypes == 'datetime64[ns]']
+        time_cols = df.columns[df.dtypes == 'timedelta64[ns]']
+        complex_cols = df.columns[df.dtypes == 'complex128']
+        period_cols = df.columns[df.dtypes == 'period[M]']
+        if len(date_cols) != 0 or len(time_cols) != 0 or len(complex_cols) != 0 or len(period_cols) != 0:
+            df = df.copy()
+            df[date_cols] = df[date_cols].astype(str)
+            df[time_cols] = df[time_cols].astype(str)
+            df[complex_cols] = df[complex_cols].astype(str)
+            df[period_cols] = df[period_cols].astype(str)
+
         return AgGrid({
-            'columnDefs': [{'field': col} for col in df.columns],
+            'columnDefs': [{'field': str(col)} for col in df.columns],
             'rowData': df.to_dict('records'),
-        }, theme=theme)
+            'suppressDotNotation': True,
+            **options,
+        }, theme=theme, auto_size_columns=auto_size_columns)
 
     @property
     def options(self) -> Dict:
@@ -55,6 +93,16 @@ class AgGrid(Element, component='aggrid.js', libraries=['lib/aggrid/ag-grid-comm
         """
         self.run_method('call_api_method', name, *args)
 
+    def call_column_api_method(self, name: str, *args) -> None:
+        """Call an AG Grid Column API method.
+
+        See `AG Grid Column API <https://www.ag-grid.com/javascript-data-grid/column-api/>`_ for a list of methods.
+
+        :param name: name of the method
+        :param args: arguments to pass to the method
+        """
+        self.run_method('call_column_api_method', name, *args)
+
     async def get_selected_rows(self) -> List[Dict]:
         """Get the currently selected rows.
 

+ 9 - 0
nicegui/elements/audio.js

@@ -21,5 +21,14 @@ export default {
     compute_src() {
       this.computed_src = (this.src.startsWith("/") ? window.path_prefix : "") + this.src;
     },
+    seek(seconds) {
+      this.$el.currentTime = seconds;
+    },
+    play() {
+      this.$el.play();
+    },
+    pause() {
+      this.$el.pause();
+    },
   },
 };

+ 20 - 3
nicegui/elements/audio.py

@@ -2,7 +2,7 @@ import warnings
 from pathlib import Path
 from typing import Union
 
-from .. import globals
+from .. import globals  # pylint: disable=redefined-builtin
 from ..element import Element
 
 
@@ -13,10 +13,12 @@ class Audio(Element, component='audio.js'):
                  autoplay: bool = False,
                  muted: bool = False,
                  loop: bool = False,
-                 type: str = '',  # DEPRECATED
+                 type: str = '',  # DEPRECATED, pylint: disable=redefined-builtin
                  ) -> None:
         """Audio
 
+        Displays an audio player.
+
         :param src: URL or local file path of the audio source
         :param controls: whether to show the audio controls, like play, pause, and volume (default: `True`)
         :param autoplay: whether to start playing the audio automatically (default: `False`)
@@ -36,5 +38,20 @@ class Audio(Element, component='audio.js'):
         self._props['loop'] = loop
 
         if type:
-            url = f'https://github.com/zauberzeug/nicegui/pull/624'
+            url = 'https://github.com/zauberzeug/nicegui/pull/624'
             warnings.warn(DeprecationWarning(f'The type parameter for ui.audio is deprecated and ineffective ({url}).'))
+
+    def seek(self, seconds: float) -> None:
+        """Seek to a specific position in the audio.
+
+        :param seconds: the position in seconds
+        """
+        self.run_method('seek', seconds)
+
+    def play(self) -> None:
+        """Play audio."""
+        self.run_method('play')
+
+    def pause(self) -> None:
+        """Pause audio."""
+        self.run_method('pause')

+ 12 - 2
nicegui/elements/card.py

@@ -1,3 +1,5 @@
+from typing_extensions import Self
+
 from ..element import Element
 
 
@@ -18,8 +20,8 @@ class Card(Element):
         super().__init__('q-card')
         self._classes = ['nicegui-card']
 
-    def tight(self):
-        """Removes padding and gaps between nested elements."""
+    def tight(self) -> Self:
+        """Remove padding and gaps between nested elements."""
         self._classes.clear()
         self._style.clear()
         return self
@@ -28,10 +30,18 @@ class Card(Element):
 class CardSection(Element):
 
     def __init__(self) -> None:
+        """Card Section
+
+        This element is based on Quasar's `QCardSection <https://quasar.dev/vue-components/card#qcardsection-api>`_ component.
+        """
         super().__init__('q-card-section')
 
 
 class CardActions(Element):
 
     def __init__(self) -> None:
+        """Card Actions
+
+        This element is based on Quasar's `QCardActions <https://quasar.dev/vue-components/card#qcardactions-api>`_ component.
+        """
         super().__init__('q-card-actions')

+ 5 - 3
nicegui/elements/carousel.py

@@ -2,7 +2,7 @@ from __future__ import annotations
 
 from typing import Any, Callable, Optional, Union, cast
 
-from .. import globals
+from .. import globals  # pylint: disable=redefined-builtin
 from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
@@ -33,19 +33,21 @@ class Carousel(ValueElement):
         self._props['navigation'] = navigation
 
     def _value_to_model_value(self, value: Any) -> Any:
-        return value._props['name'] if isinstance(value, CarouselSlide) else value
+        return value._props['name'] if isinstance(value, CarouselSlide) else value  # pylint: disable=protected-access
 
     def on_value_change(self, value: Any) -> None:
         super().on_value_change(value)
-        names = [slide._props['name'] for slide in self]
+        names = [slide._props['name'] for slide in self]  # pylint: disable=protected-access
         for i, slide in enumerate(self):
             done = i < names.index(value) if value in names else False
             slide.props(f':done={done}')
 
     def next(self) -> None:
+        """Show the next slide."""
         self.run_method('next')
 
     def previous(self) -> None:
+        """Show the previous slide."""
         self.run_method('previous')
 
 

+ 20 - 0
nicegui/elements/chart.js

@@ -5,6 +5,26 @@ export default {
       const imports = this.extras.map((extra) => import(window.path_prefix + extra));
       Promise.allSettled(imports).then(() => {
         this.seriesCount = this.options.series ? this.options.series.length : 0;
+        this.options.plotOptions = this.options.plotOptions ?? {};
+        this.options.plotOptions.series = this.options.plotOptions.series ?? {};
+        this.options.plotOptions.series.point = this.options.plotOptions.series.point ?? {};
+        this.options.plotOptions.series.point.events = this.options.plotOptions.series.point.events ?? {};
+        function uncycle(e) {
+          // Highcharts events are cyclic, so we need to uncycle them
+          let { point, target, ...rest } = e;
+          point = point ?? target;
+          return {
+            ...rest,
+            point_index: point?.index,
+            point_x: point?.x,
+            point_y: point?.y,
+            series_index: point?.series?.index,
+          };
+        }
+        this.options.plotOptions.series.point.events.click = (e) => this.$emit("pointClick", uncycle(e));
+        this.options.plotOptions.series.point.events.dragStart = (e) => this.$emit("pointDragStart", uncycle(e));
+        this.options.plotOptions.series.point.events.drag = (e) => this.$emit("pointDrag", uncycle(e));
+        this.options.plotOptions.series.point.events.drop = (e) => this.$emit("pointDrop", uncycle(e));
         this.chart = Highcharts[this.type](this.$el, this.options);
         this.chart.reflow();
       });

+ 62 - 2
nicegui/elements/chart.py

@@ -1,6 +1,8 @@
-from typing import Dict, List
+from typing import Callable, Dict, List, Optional
 
 from ..element import Element
+from ..events import (ChartPointClickEventArguments, ChartPointDragEventArguments, ChartPointDragStartEventArguments,
+                      ChartPointDropEventArguments, GenericEventArguments, handle_event)
 
 
 class Chart(Element,
@@ -8,7 +10,13 @@ class Chart(Element,
             libraries=['lib/highcharts/*.js'],
             extra_libraries=['lib/highcharts/modules/*.js']):
 
-    def __init__(self, options: Dict, *, type: str = 'chart', extras: List[str] = []) -> None:
+    def __init__(self, options: Dict, *,
+                 type: str = 'chart', extras: List[str] = [],  # pylint: disable=redefined-builtin
+                 on_point_click: Optional[Callable] = None,
+                 on_point_drag_start: Optional[Callable] = None,
+                 on_point_drag: Optional[Callable] = None,
+                 on_point_drop: Optional[Callable] = None,
+                 ) -> None:
         """Chart
 
         An element to create a chart using `Highcharts <https://www.highcharts.com/>`_.
@@ -21,6 +29,10 @@ class Chart(Element,
         :param options: dictionary of Highcharts options
         :param type: chart type (e.g. "chart", "stockChart", "mapChart", ...; default: "chart")
         :param extras: list of extra dependencies to include (e.g. "annotations", "arc-diagram", "solid-gauge", ...)
+        :param on_point_click: callback function that is called when a point is clicked
+        :param on_point_drag_start: callback function that is called when a point drag starts
+        :param on_point_drag: callback function that is called when a point is dragged
+        :param on_point_drop: callback function that is called when a point is dropped
         """
         super().__init__()
         self._props['type'] = type
@@ -28,6 +40,54 @@ class Chart(Element,
         self._props['extras'] = extras
         self.libraries.extend(library for library in self.extra_libraries if library.path.stem in extras)
 
+        if on_point_click:
+            def handle_point_click(e: GenericEventArguments) -> None:
+                handle_event(on_point_click, ChartPointClickEventArguments(
+                    sender=self,
+                    client=self.client,
+                    event_type='point_click',
+                    point_index=e.args['point_index'],
+                    point_x=e.args['point_x'],
+                    point_y=e.args['point_y'],
+                    series_index=e.args['series_index'],
+                ))
+            self.on('pointClick', handle_point_click, ['point_index', 'point_x', 'point_y', 'series_index'])
+
+        if on_point_drag_start:
+            def handle_point_dragStart(_: GenericEventArguments) -> None:
+                handle_event(on_point_drag_start, ChartPointDragStartEventArguments(
+                    sender=self,
+                    client=self.client,
+                    event_type='point_drag_start',
+                ))
+            self.on('pointDragStart', handle_point_dragStart, [])
+
+        if on_point_drag:
+            def handle_point_drag(e: GenericEventArguments) -> None:
+                handle_event(on_point_drag, ChartPointDragEventArguments(
+                    sender=self,
+                    client=self.client,
+                    event_type='point_drag',
+                    point_index=e.args['point_index'],
+                    point_x=e.args['point_x'],
+                    point_y=e.args['point_y'],
+                    series_index=e.args['series_index'],
+                ))
+            self.on('pointDrag', handle_point_drag, ['point_index', 'point_x', 'point_y', 'series_index'])
+
+        if on_point_drop:
+            def handle_point_drop(e: GenericEventArguments) -> None:
+                handle_event(on_point_drop, ChartPointDropEventArguments(
+                    sender=self,
+                    client=self.client,
+                    event_type='point_drop',
+                    point_index=e.args['point_index'],
+                    point_x=e.args['point_x'],
+                    point_y=e.args['point_y'],
+                    series_index=e.args['series_index'],
+                ))
+            self.on('pointDrop', handle_point_drop, ['point_index', 'point_x', 'point_y', 'series_index'])
+
     @property
     def options(self) -> Dict:
         return self._props['options']

+ 2 - 0
nicegui/elements/checkbox.py

@@ -10,6 +10,8 @@ class Checkbox(TextElement, ValueElement, DisableableElement):
     def __init__(self, text: str = '', *, value: bool = False, on_change: Optional[Callable[..., Any]] = None) -> None:
         """Checkbox
 
+        This element is based on Quasar's `QCheckbox <https://quasar.dev/vue-components/checkbox>`_ component.
+
         :param text: the label to display next to the checkbox
         :param value: whether it should be checked initially (default: `False`)
         :param on_change: callback to execute when value changes

+ 11 - 0
nicegui/elements/choice_element.py

@@ -33,3 +33,14 @@ class ChoiceElement(ValueElement):
         self._update_values_and_labels()
         self._update_options()
         super().update()
+
+    def set_options(self, options: Union[List, Dict], *, value: Any = None) -> None:
+        """Set the options of this choice element.
+
+        :param options: The new options.
+        :param value: The new value. If not given, the current value is kept.
+        """
+        self.options = options
+        if value is not None:
+            self.value = value
+        self.update()

+ 35 - 0
nicegui/elements/code.py

@@ -0,0 +1,35 @@
+import asyncio
+from typing import Optional
+
+from ..element import Element
+from ..elements.button import Button as button
+from ..elements.markdown import Markdown as markdown
+from ..elements.markdown import remove_indentation
+from ..functions.javascript import run_javascript
+
+
+class Code(Element):
+
+    def __init__(self, content: str, *, language: Optional[str] = 'python') -> None:
+        """Code
+
+        This element displays a code block with syntax highlighting.
+
+        :param content: code to display
+        :param language: language of the code (default: "python")
+        """
+        super().__init__()
+        self._classes.append('nicegui-code')
+
+        self.content = remove_indentation(content)
+
+        with self:
+            self.markdown = markdown(f'```{language}\n{self.content}\n```').classes('overflow-auto')
+            self.copy_button = button(icon='content_copy', on_click=self.copy_to_clipboard) \
+                .props('round flat size=sm').classes('absolute right-2 top-2 opacity-20 hover:opacity-80')
+
+    async def copy_to_clipboard(self) -> None:
+        await run_javascript('navigator.clipboard.writeText(`' + self.content + '`)', respond=False)
+        self.copy_button.props('icon=check')
+        await asyncio.sleep(3.0)
+        self.copy_button.props('icon=content_copy')

+ 23 - 5
nicegui/elements/color_input.py

@@ -1,8 +1,7 @@
 from typing import Any, Callable, Optional
 
-from nicegui import ui
-
-from .color_picker import ColorPicker
+from .button import Button as button
+from .color_picker import ColorPicker as color_picker
 from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
@@ -15,13 +14,17 @@ class ColorInput(ValueElement, DisableableElement):
                  placeholder: Optional[str] = None,
                  value: str = '',
                  on_change: Optional[Callable[..., Any]] = None,
+                 preview: bool = False,
                  ) -> None:
         """Color Input
 
+        This element extends Quasar's `QInput <https://quasar.dev/vue-components/input>`_ component with a color picker.
+
         :param label: displayed label for the color input
         :param placeholder: text to show if no color is selected
         :param value: the current color value
         :param on_change: callback to execute when the value changes
+        :param preview: change button background to selected color (default: False)
         """
         super().__init__(tag='q-input', value=value, on_value_change=on_change)
         if label is not None:
@@ -30,12 +33,27 @@ class ColorInput(ValueElement, DisableableElement):
             self._props['placeholder'] = placeholder
 
         with self.add_slot('append'):
-            self.picker = ColorPicker(on_pick=lambda e: self.set_value(e.color))
-            self.button = ui.button(on_click=self.open_picker, icon='colorize') \
+            self.picker = color_picker(on_pick=lambda e: self.set_value(e.color))
+            self.button = button(on_click=self.open_picker, icon='colorize') \
                 .props('flat round', remove='color').classes('cursor-pointer')
 
+        self.preview = preview
+        self._update_preview()
+
     def open_picker(self) -> None:
         """Open the color picker"""
         if self.value:
             self.picker.set_color(self.value)
         self.picker.open()
+
+    def on_value_change(self, value: Any) -> None:
+        super().on_value_change(value)
+        self._update_preview()
+
+    def _update_preview(self) -> None:
+        if not self.preview:
+            return
+        self.button.style(f'''
+            background-color: {(self.value or "#fff").split(";", 1)[0]};
+            text-shadow: 2px 0 #fff, -2px 0 #fff, 0 2px #fff, 0 -2px #fff, 1px 1px #fff, -1px -1px #fff, 1px -1px #fff, -1px 1px #fff;
+        ''')

+ 4 - 1
nicegui/elements/color_picker.py

@@ -10,6 +10,9 @@ class ColorPicker(Menu):
     def __init__(self, *, on_pick: Callable[..., Any], value: bool = False) -> None:
         """Color Picker
 
+        This element is based on Quasar's `QMenu <https://quasar.dev/vue-components/menu>`_ and
+        `QColor <https://quasar.dev/vue-components/color>`_ components.
+
         :param on_pick: callback to execute when a color is picked
         :param value: whether the menu is already opened (default: `False`)
         """
@@ -20,7 +23,7 @@ class ColorPicker(Menu):
             self.q_color = Element('q-color').on('change', handle_change)
 
     def set_color(self, color: str) -> None:
-        """Set the color of the picker
+        """Set the color of the picker.
 
         :param color: the color to set
         """

+ 2 - 2
nicegui/elements/column.py

@@ -4,9 +4,9 @@ from ..element import Element
 class Column(Element):
 
     def __init__(self) -> None:
-        '''Column Element
+        """Column Element
 
         Provides a container which arranges its child in a column.
-        '''
+        """
         super().__init__('div')
         self._classes = ['nicegui-column']

+ 2 - 2
nicegui/elements/dialog.py

@@ -9,7 +9,7 @@ class Dialog(ValueElement):
     def __init__(self, *, value: bool = False) -> None:
         """Dialog
 
-        Creates a dialog.
+        Creates a dialog based on Quasar's `QDialog <https://quasar.dev/vue-components/dialog>`_ component.
         By default it is dismissible by clicking or pressing ESC.
         To make it persistent, set `.props('persistent')` on the dialog element.
 
@@ -35,7 +35,7 @@ class Dialog(ValueElement):
         self._result = None
         self.submitted.clear()
         self.open()
-        yield from self.submitted.wait().__await__()
+        yield from self.submitted.wait().__await__()  # pylint: disable=no-member
         result = self._result
         self.close()
         return result

+ 26 - 0
nicegui/elements/echart.js

@@ -0,0 +1,26 @@
+import { convertDynamicProperties } from "../../static/utils/dynamic_properties.js";
+
+export default {
+  template: "<div></div>",
+  mounted() {
+    this.chart = echarts.init(this.$el);
+    this.chart.on("click", (e) => this.$emit("pointClick", e));
+    this.update_chart();
+    new ResizeObserver(this.chart.resize).observe(this.$el);
+  },
+  beforeDestroy() {
+    this.chart.dispose();
+  },
+  beforeUnmount() {
+    this.chart.dispose();
+  },
+  methods: {
+    update_chart() {
+      convertDynamicProperties(this.options, true);
+      this.chart.setOption(this.options);
+    },
+  },
+  props: {
+    options: Object,
+  },
+};

+ 56 - 0
nicegui/elements/echart.py

@@ -0,0 +1,56 @@
+from typing import Callable, Dict, Optional
+
+from ..element import Element
+from ..events import EChartPointClickEventArguments, GenericEventArguments, handle_event
+
+
+class EChart(Element, component='echart.js', libraries=['lib/echarts/echarts.min.js']):
+
+    def __init__(self, options: Dict, on_point_click: Optional[Callable] = None) -> None:
+        """Apache EChart
+
+        An element to create a chart using `ECharts <https://echarts.apache.org/>`_.
+        Updates can be pushed to the chart by changing the `options` property.
+        After data has changed, call the `update` method to refresh the chart.
+
+        :param options: dictionary of EChart options
+        :param on_click_point: callback function that is called when a point is clicked
+        """
+        super().__init__()
+        self._props['options'] = options
+        self._classes = ['nicegui-echart']
+
+        if on_point_click:
+            def handle_point_click(e: GenericEventArguments) -> None:
+                handle_event(on_point_click, EChartPointClickEventArguments(
+                    sender=self,
+                    client=self.client,
+                    component_type=e.args['componentType'],
+                    series_type=e.args['seriesType'],
+                    series_index=e.args['seriesIndex'],
+                    series_name=e.args['seriesName'],
+                    name=e.args['name'],
+                    data_index=e.args['dataIndex'],
+                    data=e.args['data'],
+                    data_type=e.args.get('dataType'),
+                    value=e.args['value'],
+                ))
+            self.on('pointClick', handle_point_click, [
+                'componentType',
+                'seriesType',
+                'seriesIndex',
+                'seriesName',
+                'name',
+                'dataIndex',
+                'data',
+                'dataType',
+                'value',
+            ])
+
+    @property
+    def options(self) -> Dict:
+        return self._props['options']
+
+    def update(self) -> None:
+        super().update()
+        self.run_method('update_chart')

+ 25 - 0
nicegui/elements/editor.py

@@ -0,0 +1,25 @@
+from typing import Any, Callable, Optional
+
+from .mixins.disableable_element import DisableableElement
+from .mixins.value_element import ValueElement
+
+
+class Editor(ValueElement, DisableableElement):
+
+    def __init__(self,
+                 *,
+                 placeholder: Optional[str] = None,
+                 value: str = '',
+                 on_change: Optional[Callable[..., Any]] = None,
+                 ) -> None:
+        """Editor
+
+        A WYSIWYG editor based on `Quasar's QEditor <https://quasar.dev/vue-components/editor>`_.
+        The value is a string containing the formatted text as HTML code.
+
+        :param value: initial value
+        :param on_change: callback to be invoked when the value changes
+        """
+        super().__init__(tag='q-editor', value=value, on_value_change=on_change)
+        if placeholder is not None:
+            self._props['placeholder'] = placeholder

+ 14 - 6
nicegui/elements/expansion.py

@@ -1,4 +1,4 @@
-from typing import Optional
+from typing import Any, Callable, Optional
 
 from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
@@ -6,22 +6,30 @@ from .mixins.value_element import ValueElement
 
 class Expansion(ValueElement, DisableableElement):
 
-    def __init__(self, text: Optional[str] = None, *, icon: Optional[str] = None, value: bool = False) -> None:
-        '''Expansion Element
+    def __init__(self,
+                 text: Optional[str] = None, *,
+                 icon: Optional[str] = None,
+                 value: bool = False,
+                 on_value_change: Optional[Callable[..., Any]] = None
+                 ) -> None:
+        """Expansion Element
 
-        Provides an expandable container.
+        Provides an expandable container based on Quasar's `QExpansionItem <https://quasar.dev/vue-components/expansion-item>`_ component.
 
         :param text: title text
         :param icon: optional icon (default: None)
         :param value: whether the expansion should be opened on creation (default: `False`)
-        '''
-        super().__init__(tag='q-expansion-item', value=value, on_value_change=None)
+        :param on_value_change: callback to execute when value changes
+        """
+        super().__init__(tag='q-expansion-item', value=value, on_value_change=on_value_change)
         if text is not None:
             self._props['label'] = text
         self._props['icon'] = icon
 
     def open(self) -> None:
+        """Open the expansion."""
         self.value = True
 
     def close(self) -> None:
+        """Close the expansion."""
         self.value = False

+ 2 - 2
nicegui/elements/grid.py

@@ -9,13 +9,13 @@ class Grid(Element):
                  rows: Optional[int] = None,
                  columns: Optional[int] = None,
                  ) -> None:
-        '''Grid Element
+        """Grid Element
 
         Provides a container which arranges its child in a grid.
 
         :param rows: number of rows in the grid
         :param columns: number of columns in the grid
-        '''
+        """
         super().__init__('div')
         self._classes = ['nicegui-grid']
         if rows is not None:

+ 5 - 5
nicegui/elements/icon.py

@@ -1,9 +1,10 @@
 from typing import Optional
 
 from .mixins.color_elements import TextColorElement
+from .mixins.name_element import NameElement
 
 
-class Icon(TextColorElement):
+class Icon(NameElement, TextColorElement):
 
     def __init__(self,
                  name: str,
@@ -15,14 +16,13 @@ class Icon(TextColorElement):
 
         This element is based on Quasar's `QIcon <https://quasar.dev/vue-components/icon>`_ component.
 
-        `Here <https://fonts.google.com/icons>`_ is a reference of possible names.
+        `Here <https://fonts.google.com/icons?icon.set=Material+Icons>`_ is a reference of possible names.
 
-        :param name: name of the icon
+        :param name: name of the icon (snake case, e.g. `add_circle`)
         :param size: size in CSS units, including unit name or standard size name (xs|sm|md|lg|xl), examples: 16px, 2rem
         :param color: icon color (either a Quasar, Tailwind, or CSS color or `None`, default: `None`)
         """
-        super().__init__(tag='q-icon', text_color=color)
-        self._props['name'] = name
+        super().__init__(tag='q-icon', name=name, text_color=color)
 
         if size:
             self._props['size'] = size

+ 1 - 0
nicegui/elements/image.py

@@ -10,6 +10,7 @@ class Image(SourceElement, component='image.js'):
         """Image
 
         Displays an image.
+        This element is based on Quasar's `QImg <https://quasar.dev/vue-components/img>`_ component.
 
         :param source: the source of the image; can be a URL, local file path or a base64 string
         """

+ 21 - 11
nicegui/elements/interactive_image.js

@@ -1,7 +1,15 @@
 export default {
   template: `
     <div style="position:relative">
-      <img ref="img" :src="computed_src" style="width:100%; height:100%;" v-on="onEvents" draggable="false" />
+      <img
+        ref="img"
+        :src="computed_src"
+        style="width:100%; height:100%;"
+        @load="onImageLoaded"
+        v-on="onCrossEvents"
+        v-on="onUserEvents"
+        draggable="false"
+      />
       <svg style="position:absolute;top:0;left:0;pointer-events:none" :viewBox="viewBox">
         <g v-if="cross" :style="{ display: cssDisplay }">
           <line :x1="x" y1="0" :x2="x" y2="100%" stroke="black" />
@@ -74,18 +82,20 @@ export default {
     },
   },
   computed: {
-    onEvents() {
-      const allEvents = {};
+    onCrossEvents() {
+      if (!this.cross) return {};
+      return {
+        mouseenter: () => (this.cssDisplay = "block"),
+        mouseleave: () => (this.cssDisplay = "none"),
+        mousemove: (event) => this.updateCrossHair(event),
+      };
+    },
+    onUserEvents() {
+      const events = {};
       for (const type of this.events) {
-        allEvents[type] = (event) => this.onMouseEvent(type, event);
-      }
-      if (this.cross) {
-        allEvents["mouseenter"] = () => (this.cssDisplay = "block");
-        allEvents["mouseleave"] = () => (this.cssDisplay = "none");
-        allEvents["mousemove"] = (event) => this.updateCrossHair(event);
+        events[type] = (event) => this.onMouseEvent(type, event);
       }
-      allEvents["load"] = (event) => this.onImageLoaded(event);
-      return allEvents;
+      return events;
     },
   },
   props: {

+ 13 - 12
nicegui/elements/interactive_image.py

@@ -1,7 +1,7 @@
 from __future__ import annotations
 
 from pathlib import Path
-from typing import Any, Callable, List, Optional, Union
+from typing import Any, Callable, List, Optional, Union, cast
 
 from ..events import GenericEventArguments, MouseEventArguments, handle_event
 from .mixins.content_element import ContentElement
@@ -27,7 +27,7 @@ class InteractiveImage(SourceElement, ContentElement, component='interactive_ima
         See `OpenCV Webcam <https://github.com/zauberzeug/nicegui/tree/main/examples/opencv_webcam/main.py>`_ for an example.
 
         :param source: the source of the image; can be an URL, local file path or a base64 string
-        :param content: SVG content which should be overlayed; viewport has the same dimensions as the image
+        :param content: SVG content which should be overlaid; viewport has the same dimensions as the image
         :param on_mouse: callback for mouse events (yields `type`, `image_x` and `image_y`)
         :param events: list of JavaScript events to subscribe to (default: `['click']`)
         :param cross: whether to show crosshairs (default: `False`)
@@ -39,18 +39,19 @@ class InteractiveImage(SourceElement, ContentElement, component='interactive_ima
         def handle_mouse(e: GenericEventArguments) -> None:
             if on_mouse is None:
                 return
+            args = cast(dict, e.args)
             arguments = MouseEventArguments(
                 sender=self,
                 client=self.client,
-                type=e.args.get('mouse_event_type'),
-                image_x=e.args.get('image_x'),
-                image_y=e.args.get('image_y'),
-                button=e.args.get('button', 0),
-                buttons=e.args.get('buttons', 0),
-                alt=e.args.get('alt', False),
-                ctrl=e.args.get('ctrl', False),
-                meta=e.args.get('meta', False),
-                shift=e.args.get('shift', False),
+                type=args.get('mouse_event_type', ''),
+                image_x=args.get('image_x', 0.0),
+                image_y=args.get('image_y', 0.0),
+                button=args.get('button', 0),
+                buttons=args.get('buttons', 0),
+                alt=args.get('alt', False),
+                ctrl=args.get('ctrl', False),
+                meta=args.get('meta', False),
+                shift=args.get('shift', False),
             )
-            return handle_event(on_mouse, arguments)
+            handle_event(on_mouse, arguments)
         self.on('mouse', handle_mouse)

+ 1 - 1
nicegui/elements/joystick.py

@@ -47,5 +47,5 @@ class Joystick(Element, component='joystick.vue', libraries=['lib/nipplejs/nippl
                                                         action='end'))
 
         self.on('start', handle_start, [])
-        self.on('move', handle_move, ['data'], throttle=throttle),
+        self.on('move', handle_move, ['data'], throttle=throttle)
         self.on('end', handle_end, [])

+ 38 - 0
nicegui/elements/json_editor.js

@@ -0,0 +1,38 @@
+import { JSONEditor } from "index";
+
+export default {
+  template: "<div></div>",
+  mounted() {
+    this.properties.onChange = (updatedContent, previousContent, { contentErrors, patchResult }) => {
+      this.$emit("change", { content: updatedContent, errors: contentErrors });
+    };
+    this.properties.onSelect = (selection) => {
+      this.$emit("select", { selection: selection });
+    };
+    this.editor = new JSONEditor({
+      target: this.$el,
+      props: this.properties,
+    });
+  },
+  beforeDestroy() {
+    this.destroyEditor();
+  },
+  beforeUnmount() {
+    this.destroyEditor();
+  },
+  methods: {
+    update_editor() {
+      if (this.editor) {
+        this.editor.updateProps(this.properties);
+      }
+    },
+    destroyEditor() {
+      if (this.editor) {
+        this.editor.dispose();
+      }
+    },
+  },
+  props: {
+    properties: Object,
+  },
+};

+ 43 - 0
nicegui/elements/json_editor.py

@@ -0,0 +1,43 @@
+from typing import Callable, Dict, Optional
+
+from ..element import Element
+from ..events import GenericEventArguments, JsonEditorChangeEventArguments, JsonEditorSelectEventArguments, handle_event
+
+
+class JsonEditor(Element, component='json_editor.js', exposed_libraries=['lib/vanilla-jsoneditor/index.js']):
+
+    def __init__(self,
+                 properties: Dict, *,
+                 on_select: Optional[Callable] = None,
+                 on_change: Optional[Callable] = None,
+                 ) -> None:
+        """JSONEditor
+
+        An element to create a JSON editor using `JSONEditor <https://github.com/josdejong/svelte-jsoneditor>`_.
+        Updates can be pushed to the editor by changing the `properties` property.
+        After data has changed, call the `update` method to refresh the editor.
+
+        :param properties: dictionary of JSONEditor properties
+        :param on_select: callback function that is called when some of the content has been selected
+        :param on_change: callback function that is called when the content has changed
+        """
+        super().__init__()
+        self._props['properties'] = properties
+
+        if on_select:
+            def handle_on_select(e: GenericEventArguments) -> None:
+                handle_event(on_select, JsonEditorSelectEventArguments(sender=self, client=self.client, **e.args))
+            self.on('select', handle_on_select, ['selection'])
+
+        if on_change:
+            def handle_on_change(e: GenericEventArguments) -> None:
+                handle_event(on_change, JsonEditorChangeEventArguments(sender=self, client=self.client, **e.args))
+            self.on('change', handle_on_change, ['content', 'errors'])
+
+    @property
+    def properties(self) -> Dict:
+        return self._props['properties']
+
+    def update(self) -> None:
+        super().update()
+        self.run_method('update_editor')

+ 3 - 2
nicegui/elements/keyboard.py

@@ -13,7 +13,8 @@ class Keyboard(Element, component='keyboard.js'):
                  on_key: Callable[..., Any], *,
                  active: bool = True,
                  repeating: bool = True,
-                 ignore: List[Literal['input', 'select', 'button', 'textarea']] = ['input', 'select', 'button', 'textarea'],
+                 ignore: List[Literal['input', 'select', 'button', 'textarea']] = [
+                     'input', 'select', 'button', 'textarea'],
                  ) -> None:
         """Keyboard
 
@@ -59,4 +60,4 @@ class Keyboard(Element, component='keyboard.js'):
             modifiers=modifiers,
             key=key,
         )
-        return handle_event(self.key_handler, arguments)
+        handle_event(self.key_handler, arguments)

+ 2 - 2
nicegui/elements/knob.py

@@ -11,8 +11,8 @@ class Knob(ValueElement, DisableableElement, TextColorElement):
     def __init__(self,
                  value: float = 0.0,
                  *,
-                 min: float = 0.0,
-                 max: float = 1.0,
+                 min: float = 0.0,  # pylint: disable=redefined-builtin
+                 max: float = 1.0,  # pylint: disable=redefined-builtin
                  step: float = 0.01,
                  color: Optional[str] = 'primary',
                  center_color: Optional[str] = None,

文件差异内容过多而无法显示
+ 0 - 0
nicegui/elements/lib/echarts/echarts.js.map


文件差异内容过多而无法显示
+ 34 - 0
nicegui/elements/lib/echarts/echarts.min.js


+ 220 - 0
nicegui/elements/lib/three/modules/DragControls.js

@@ -0,0 +1,220 @@
+import {
+	EventDispatcher,
+	Matrix4,
+	Plane,
+	Raycaster,
+	Vector2,
+	Vector3
+} from 'three';
+
+const _plane = new Plane();
+const _raycaster = new Raycaster();
+
+const _pointer = new Vector2();
+const _offset = new Vector3();
+const _intersection = new Vector3();
+const _worldPosition = new Vector3();
+const _inverseMatrix = new Matrix4();
+
+class DragControls extends EventDispatcher {
+
+	constructor( _objects, _camera, _domElement ) {
+
+		super();
+
+		_domElement.style.touchAction = 'none'; // disable touch scroll
+
+		let _selected = null, _hovered = null;
+
+		const _intersections = [];
+
+		//
+
+		const scope = this;
+
+		function activate() {
+
+			_domElement.addEventListener( 'pointermove', onPointerMove );
+			_domElement.addEventListener( 'pointerdown', onPointerDown );
+			_domElement.addEventListener( 'pointerup', onPointerCancel );
+			_domElement.addEventListener( 'pointerleave', onPointerCancel );
+
+		}
+
+		function deactivate() {
+
+			_domElement.removeEventListener( 'pointermove', onPointerMove );
+			_domElement.removeEventListener( 'pointerdown', onPointerDown );
+			_domElement.removeEventListener( 'pointerup', onPointerCancel );
+			_domElement.removeEventListener( 'pointerleave', onPointerCancel );
+
+			_domElement.style.cursor = '';
+
+		}
+
+		function dispose() {
+
+			deactivate();
+
+		}
+
+		function getObjects() {
+
+			return _objects;
+
+		}
+
+		function getRaycaster() {
+
+			return _raycaster;
+
+		}
+
+		function onPointerMove( event ) {
+
+			if ( scope.enabled === false ) return;
+
+			updatePointer( event );
+
+			_raycaster.setFromCamera( _pointer, _camera );
+
+			if ( _selected ) {
+
+				if ( _raycaster.ray.intersectPlane( _plane, _intersection ) ) {
+
+					_selected.position.copy( _intersection.sub( _offset ).applyMatrix4( _inverseMatrix ) );
+
+				}
+
+				scope.dispatchEvent( { type: 'drag', object: _selected } );
+
+				return;
+
+			}
+
+			// hover support
+
+			if ( event.pointerType === 'mouse' || event.pointerType === 'pen' ) {
+
+				_intersections.length = 0;
+
+				_raycaster.setFromCamera( _pointer, _camera );
+				_raycaster.intersectObjects( _objects, true, _intersections );
+
+				if ( _intersections.length > 0 ) {
+
+					const object = _intersections[ 0 ].object;
+
+					_plane.setFromNormalAndCoplanarPoint( _camera.getWorldDirection( _plane.normal ), _worldPosition.setFromMatrixPosition( object.matrixWorld ) );
+
+					if ( _hovered !== object && _hovered !== null ) {
+
+						scope.dispatchEvent( { type: 'hoveroff', object: _hovered } );
+
+						_domElement.style.cursor = 'auto';
+						_hovered = null;
+
+					}
+
+					if ( _hovered !== object ) {
+
+						scope.dispatchEvent( { type: 'hoveron', object: object } );
+
+						_domElement.style.cursor = 'pointer';
+						_hovered = object;
+
+					}
+
+				} else {
+
+					if ( _hovered !== null ) {
+
+						scope.dispatchEvent( { type: 'hoveroff', object: _hovered } );
+
+						_domElement.style.cursor = 'auto';
+						_hovered = null;
+
+					}
+
+				}
+
+			}
+
+		}
+
+		function onPointerDown( event ) {
+
+			if ( scope.enabled === false ) return;
+
+			updatePointer( event );
+
+			_intersections.length = 0;
+
+			_raycaster.setFromCamera( _pointer, _camera );
+			_raycaster.intersectObjects( _objects, true, _intersections );
+
+			if ( _intersections.length > 0 ) {
+
+				_selected = ( scope.transformGroup === true ) ? _objects[ 0 ] : _intersections[ 0 ].object;
+
+				_plane.setFromNormalAndCoplanarPoint( _camera.getWorldDirection( _plane.normal ), _worldPosition.setFromMatrixPosition( _selected.matrixWorld ) );
+
+				if ( _raycaster.ray.intersectPlane( _plane, _intersection ) ) {
+
+					_inverseMatrix.copy( _selected.parent.matrixWorld ).invert();
+					_offset.copy( _intersection ).sub( _worldPosition.setFromMatrixPosition( _selected.matrixWorld ) );
+
+				}
+
+				_domElement.style.cursor = 'move';
+
+				scope.dispatchEvent( { type: 'dragstart', object: _selected } );
+
+			}
+
+
+		}
+
+		function onPointerCancel() {
+
+			if ( scope.enabled === false ) return;
+
+			if ( _selected ) {
+
+				scope.dispatchEvent( { type: 'dragend', object: _selected } );
+
+				_selected = null;
+
+			}
+
+			_domElement.style.cursor = _hovered ? 'pointer' : 'auto';
+
+		}
+
+		function updatePointer( event ) {
+
+			const rect = _domElement.getBoundingClientRect();
+
+			_pointer.x = ( event.clientX - rect.left ) / rect.width * 2 - 1;
+			_pointer.y = - ( event.clientY - rect.top ) / rect.height * 2 + 1;
+
+		}
+
+		activate();
+
+		// API
+
+		this.enabled = true;
+		this.transformGroup = false;
+
+		this.activate = activate;
+		this.deactivate = deactivate;
+		this.dispose = dispose;
+		this.getObjects = getObjects;
+		this.getRaycaster = getRaycaster;
+
+	}
+
+}
+
+export { DragControls };

文件差异内容过多而无法显示
+ 0 - 0
nicegui/elements/lib/vanilla-jsoneditor/index.js


文件差异内容过多而无法显示
+ 0 - 0
nicegui/elements/lib/vanilla-jsoneditor/index.js.map


+ 24 - 3
nicegui/elements/line_plot.py

@@ -33,11 +33,21 @@ class LinePlot(Pyplot):
         self.push_counter = 0
 
     def with_legend(self, titles: List[str], **kwargs: Any):
+        """Add a legend to the plot.
+
+        :param titles: list of titles for the lines
+        :param kwargs: additional arguments which should be passed to `pyplot.legend <https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.legend.html>`_
+        """
         self.fig.gca().legend(titles, **kwargs)
         self._convert_to_html()
         return self
 
     def push(self, x: List[float], Y: List[List[float]]) -> None:
+        """Push new data to the plot.
+
+        :param x: list of x values
+        :param Y: list of lists of y values (one list per line)
+        """
         self.push_counter += 1
 
         self.x = [*self.x, *x][self.slice]
@@ -47,9 +57,9 @@ class LinePlot(Pyplot):
         if self.push_counter % self.update_every != 0:
             return
 
-        for i in range(len(self.lines)):
-            self.lines[i].set_xdata(self.x)
-            self.lines[i].set_ydata(self.Y[i])
+        for i, line in enumerate(self.lines):
+            line.set_xdata(self.x)
+            line.set_ydata(self.Y[i])
 
         flat_y = [y_i for y in self.Y for y_i in y]
         min_x = min(self.x)
@@ -62,3 +72,14 @@ class LinePlot(Pyplot):
         self.fig.gca().set_ylim(min_y - pad_y, max_y + pad_y)
         self._convert_to_html()
         self.update()
+
+    def clear(self) -> None:
+        """Clear the line plot."""
+        super().clear()
+        self.x.clear()
+        for y in self.Y:
+            y.clear()
+        for line in self.lines:
+            line.set_data([], [])
+        self._convert_to_html()
+        self.update()

+ 1 - 1
nicegui/elements/link.py

@@ -1,6 +1,6 @@
 from typing import Any, Callable, Union
 
-from .. import globals
+from .. import globals  # pylint: disable=redefined-builtin
 from ..element import Element
 from .mixins.text_element import TextElement
 

+ 6 - 2
nicegui/elements/log.py

@@ -8,7 +8,7 @@ from ..element import Element
 class Log(Element, component='log.js'):
 
     def __init__(self, max_lines: Optional[int] = None) -> None:
-        """Log view
+        """Log View
 
         Create a log view that allows to add new lines without re-transmitting the whole history to the client.
 
@@ -22,6 +22,10 @@ class Log(Element, component='log.js'):
         self.total_count: int = 0
 
     def push(self, line: Any) -> None:
+        """Add a new line to the log.
+
+        :param line: the line to add (can contain line breaks)
+        """
         new_lines = [urllib.parse.quote(line) for line in str(line).splitlines()]
         self.lines.extend(new_lines)
         self._props['lines'] = '\n'.join(self.lines)
@@ -29,7 +33,7 @@ class Log(Element, component='log.js'):
         self.run_method('push', urllib.parse.quote(str(line)), self.total_count)
 
     def clear(self) -> None:
-        """Clear the log"""
+        """Clear the log."""
         super().clear()
         self._props['lines'] = ''
         self.lines.clear()

+ 1 - 1
nicegui/elements/markdown.py

@@ -4,7 +4,7 @@ from functools import lru_cache
 from typing import List
 
 import markdown2
-from pygments.formatters import HtmlFormatter
+from pygments.formatters import HtmlFormatter  # pylint: disable=no-name-in-module
 
 from .mermaid import Mermaid
 from .mixins.content_element import ContentElement

+ 3 - 2
nicegui/elements/menu.py

@@ -1,6 +1,6 @@
 from typing import Any, Callable, Optional
 
-from .. import globals
+from .. import globals  # pylint: disable=redefined-builtin
 from ..events import ClickEventArguments, handle_event
 from .mixins.text_element import TextElement
 from .mixins.value_element import ValueElement
@@ -11,7 +11,7 @@ class Menu(ValueElement):
     def __init__(self, *, value: bool = False) -> None:
         """Menu
 
-        Creates a menu.
+        Creates a menu based on Quasar's `QMenu <https://quasar.dev/vue-components/menu>`_ component.
         The menu should be placed inside the element where it should be shown.
 
         :param value: whether the menu is already opened (default: `False`)
@@ -41,6 +41,7 @@ class MenuItem(TextElement):
         """Menu Item
 
         A menu item to be added to a menu.
+        This element is based on Quasar's `QItem <https://quasar.dev/vue-components/list-and-list-items#qitem-api>`_ component.
 
         :param text: label of the menu item
         :param on_click: callback to be executed when selecting the menu item

+ 2 - 2
nicegui/elements/mermaid.py

@@ -8,13 +8,13 @@ class Mermaid(ContentElement,
     CONTENT_PROP = 'content'
 
     def __init__(self, content: str) -> None:
-        '''Mermaid Diagrams
+        """Mermaid Diagrams
 
         Renders diagrams and charts written in the Markdown-inspired `Mermaid <https://mermaid.js.org/>`_ language.
         The mermaid syntax can also be used inside Markdown elements by providing the extension string 'mermaid' to the ``ui.markdown`` element.
 
         :param content: the Mermaid content to be displayed
-        '''
+        """
         super().__init__(content=content)
 
     def on_content_change(self, content: str) -> None:

+ 0 - 0
nicegui/elements/mixins/__init__.py


+ 5 - 5
nicegui/elements/mixins/color_elements.py

@@ -1,11 +1,11 @@
-from typing import Any, get_args
+from typing import Any, Optional, get_args
 
 from ...element import Element
 from ...tailwind_types.background_color import BackgroundColor
 
 QUASAR_COLORS = {'primary', 'secondary', 'accent', 'dark', 'positive', 'negative', 'info', 'warning'}
-for color in {'red', 'pink', 'purple', 'deep-purple', 'indigo', 'blue', 'light-blue', 'cyan', 'teal', 'green',
-              'light-green', 'lime', 'yellow', 'amber', 'orange', 'deep-orange', 'brown', 'grey', 'blue-grey'}:
+for color in ['red', 'pink', 'purple', 'deep-purple', 'indigo', 'blue', 'light-blue', 'cyan', 'teal', 'green',
+              'light-green', 'lime', 'yellow', 'amber', 'orange', 'deep-orange', 'brown', 'grey', 'blue-grey']:
     QUASAR_COLORS.add(color)
     for i in range(1, 15):
         QUASAR_COLORS.add(f'{color}-{i}')
@@ -16,7 +16,7 @@ TAILWIND_COLORS = get_args(BackgroundColor)
 class BackgroundColorElement(Element):
     BACKGROUND_COLOR_PROP = 'color'
 
-    def __init__(self, *, background_color: str, **kwargs: Any) -> None:
+    def __init__(self, *, background_color: Optional[str], **kwargs: Any) -> None:
         super().__init__(**kwargs)
         if background_color in QUASAR_COLORS:
             self._props[self.BACKGROUND_COLOR_PROP] = background_color
@@ -29,7 +29,7 @@ class BackgroundColorElement(Element):
 class TextColorElement(Element):
     TEXT_COLOR_PROP = 'color'
 
-    def __init__(self, *, text_color: str, **kwargs: Any) -> None:
+    def __init__(self, *, text_color: Optional[str], **kwargs: Any) -> None:
         super().__init__(**kwargs)
         if text_color in QUASAR_COLORS:
             self._props[self.TEXT_COLOR_PROP] = text_color

+ 5 - 5
nicegui/elements/mixins/filter_element.py

@@ -10,7 +10,7 @@ class FilterElement(Element):
     FILTER_PROP = 'filter'
     filter = BindableProperty(on_change=lambda sender, filter: sender.on_filter_change(filter))
 
-    def __init__(self, *, filter: Optional[str] = None, **kwargs: Any) -> None:
+    def __init__(self, *, filter: Optional[str] = None, **kwargs: Any) -> None:  # pylint: disable=redefined-builtin
         super().__init__(**kwargs)
         self.filter = filter
         self._props[self.FILTER_PROP] = filter
@@ -65,17 +65,17 @@ class FilterElement(Element):
         bind(self, 'filter', target_object, target_name, forward=forward, backward=backward)
         return self
 
-    def set_filter(self, filter: str) -> None:
+    def set_filter(self, filter_: str) -> None:
         """Set the filter of this element.
 
         :param filter: The new filter.
         """
-        self.filter = filter
+        self.filter = filter_
 
-    def on_filter_change(self, filter: str) -> None:
+    def on_filter_change(self, filter_: str) -> None:
         """Called when the filter of this element changes.
 
         :param filter: The new filter.
         """
-        self._props[self.FILTER_PROP] = filter
+        self._props[self.FILTER_PROP] = filter_
         self.update()

+ 80 - 0
nicegui/elements/mixins/name_element.py

@@ -0,0 +1,80 @@
+from typing import Any, Callable
+
+from typing_extensions import Self
+
+from ...binding import BindableProperty, bind, bind_from, bind_to
+from ...element import Element
+
+
+class NameElement(Element):
+    name = BindableProperty(on_change=lambda sender, name: sender.on_name_change(name))
+
+    def __init__(self, *, name: str, **kwargs: Any) -> None:
+        super().__init__(**kwargs)
+        self.name = name
+        self._props['name'] = name
+
+    def bind_name_to(self,
+                     target_object: Any,
+                     target_name: str = 'name',
+                     forward: Callable[..., Any] = lambda x: x,
+                     ) -> Self:
+        """Bind the name of this element to the target object's target_name property.
+
+        The binding works one way only, from this element to the target.
+
+        :param target_object: The object to bind to.
+        :param target_name: The name of the property to bind to.
+        :param forward: A function to apply to the value before applying it to the target.
+        """
+        bind_to(self, 'name', target_object, target_name, forward)
+        return self
+
+    def bind_name_from(self,
+                       target_object: Any,
+                       target_name: str = 'name',
+                       backward: Callable[..., Any] = lambda x: x,
+                       ) -> Self:
+        """Bind the name of this element from the target object's target_name property.
+
+        The binding works one way only, from the target to this element.
+
+        :param target_object: The object to bind from.
+        :param target_name: The name of the property to bind from.
+        :param backward: A function to apply to the value before applying it to this element.
+        """
+        bind_from(self, 'name', target_object, target_name, backward)
+        return self
+
+    def bind_name(self,
+                  target_object: Any,
+                  target_name: str = 'name', *,
+                  forward: Callable[..., Any] = lambda x: x,
+                  backward: Callable[..., Any] = lambda x: x,
+                  ) -> Self:
+        """Bind the name of this element to the target object's target_name property.
+
+        The binding works both ways, from this element to the target and from the target to this element.
+
+        :param target_object: The object to bind to.
+        :param target_name: The name of the property to bind to.
+        :param forward: A function to apply to the value before applying it to the target.
+        :param backward: A function to apply to the value before applying it to this element.
+        """
+        bind(self, 'name', target_object, target_name, forward=forward, backward=backward)
+        return self
+
+    def set_name(self, name: str) -> None:
+        """Set the name of this element.
+
+        :param name: The new name.
+        """
+        self.name = name
+
+    def on_name_change(self, name: str) -> None:
+        """Called when the name of this element changes.
+
+        :param name: The new name.
+        """
+        self._props['name'] = name
+        self.update()

+ 1 - 1
nicegui/elements/mixins/source_element.py

@@ -3,7 +3,7 @@ from typing import Any, Callable, Union
 
 from typing_extensions import Self
 
-from ... import globals
+from ... import globals  # pylint: disable=redefined-builtin
 from ...binding import BindableProperty, bind, bind_from, bind_to
 from ...element import Element
 from ...helpers import is_file

+ 7 - 3
nicegui/elements/mixins/validation_element.py

@@ -15,13 +15,17 @@ class ValidationElement(ValueElement):
         """The latest error message from the validation functions."""
         return self._error
 
-    def on_value_change(self, value: Any) -> None:
-        super().on_value_change(value)
+    def validate(self) -> None:
+        """Validate the current value and set the error message if necessary."""
         for message, check in self.validation.items():
-            if not check(value):
+            if not check(self.value):
                 self._error = message
                 self.props(f'error error-message="{message}"')
                 break
         else:
             self._error = None
             self.props(remove='error')
+
+    def on_value_change(self, value: Any) -> None:
+        super().on_value_change(value)
+        self.validate()

部分文件因为文件数量过多而无法显示