Procházet zdrojové kódy

Merge branch 'main' into pr/jacoverster/1532

Rodja Trappe před 1 rokem
rodič
revize
12d5d79b02
58 změnil soubory, kde provedl 1345 přidání a 528 odebrání
  1. 0 4
      .github/workflows/test.yml
  2. 3 11
      examples/ai_interface/main.py
  3. 32 5
      examples/authentication/main.py
  4. 40 0
      examples/custom_binding/main.py
  5. 3 10
      examples/opencv_webcam/main.py
  6. 3 12
      examples/progress/main.py
  7. 2 0
      examples/slideshow/main.py
  8. 1 0
      main.py
  9. 4 1
      nicegui/__init__.py
  10. 3 4
      nicegui/air.py
  11. 2 0
      nicegui/app.py
  12. 35 0
      nicegui/elements/code.py
  13. 9 12
      nicegui/elements/echart.js
  14. 31 2
      nicegui/elements/echart.py
  15. 25 0
      nicegui/elements/editor.py
  16. 21 11
      nicegui/elements/interactive_image.js
  17. 7 0
      nicegui/elements/stepper.py
  18. 4 4
      nicegui/elements/table.py
  19. 73 0
      nicegui/elements/timeline.py
  20. 4 1
      nicegui/elements/toggle.py
  21. 15 2
      nicegui/events.py
  22. 2 2
      nicegui/native.py
  23. 7 1
      nicegui/nicegui.py
  24. 117 89
      nicegui/observables.py
  25. 44 0
      nicegui/run_executor.py
  26. 13 2
      nicegui/static/nicegui.css
  27. 1 1
      nicegui/storage.py
  28. 8 0
      nicegui/ui.py
  29. 8 8
      nicegui/welcome.py
  30. 452 316
      poetry.lock
  31. 1 1
      pyproject.toml
  32. 1 0
      release.dockerfile
  33. 12 0
      tests/test_code.py
  34. 63 0
      tests/test_echart.py
  35. 14 0
      tests/test_editor.py
  36. 18 0
      tests/test_interactive_image.py
  37. 18 7
      tests/test_observables.py
  38. 14 1
      tests/test_serving_files.py
  39. 11 1
      tests/test_table.py
  40. 16 0
      tests/test_timeline.py
  41. 12 0
      tests/test_toggle.py
  42. 16 6
      website/demo.py
  43. 64 2
      website/documentation.py
  44. 1 1
      website/more_documentation/aggrid_documentation.py
  45. 2 2
      website/more_documentation/button_documentation.py
  46. 11 0
      website/more_documentation/code_documentation.py
  47. 25 0
      website/more_documentation/echart_documentation.py
  48. 7 0
      website/more_documentation/editor_documentation.py
  49. 5 0
      website/more_documentation/generic_events_documentation.py
  50. 2 2
      website/more_documentation/icon_documentation.py
  51. 3 3
      website/more_documentation/input_documentation.py
  52. 3 1
      website/more_documentation/menu_documentation.py
  53. 1 1
      website/more_documentation/number_documentation.py
  54. 2 0
      website/more_documentation/storage_documentation.py
  55. 26 0
      website/more_documentation/table_documentation.py
  56. 1 1
      website/more_documentation/textarea_documentation.py
  57. 16 0
      website/more_documentation/timeline_documentation.py
  58. 11 1
      website/static/search_index.json

+ 0 - 4
.github/workflows/test.yml

@@ -33,10 +33,6 @@ jobs:
         run: ./test_startup.sh
         run: ./test_startup.sh
       - name: setup chromedriver
       - name: setup chromedriver
         uses: nanasess/setup-chromedriver@v2.1.1
         uses: nanasess/setup-chromedriver@v2.1.1
-        with:
-          # XXX: This is an unfortunate workaround due to this issue:
-          # https://github.com/nanasess/setup-chromedriver/issues/199
-          chromedriver-version: "115.0.5790.102"
       - name: pytest
       - name: pytest
         run: pytest
         run: pytest
       - name: upload screenshots
       - name: upload screenshots

+ 3 - 11
examples/ai_interface/main.py

@@ -1,25 +1,17 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
-import asyncio
-import functools
 import io
 import io
-from typing import Callable
 
 
 import replicate  # very nice API to run AI models; see https://replicate.com/
 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
 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):
 async def transcribe(e: UploadEventArguments):
     transcription.text = 'Transcribing...'
     transcription.text = 'Transcribing...'
     model = replicate.models.get('openai/whisper')
     model = replicate.models.get('openai/whisper')
     version = model.versions.get('30414ee7c4fffc37e260fcab7842b5be470b9b840f2b608f5baa9bbef9a259ed')
     version = model.versions.get('30414ee7c4fffc37e260fcab7842b5be470b9b840f2b608f5baa9bbef9a259ed')
-    prediction = await io_bound(version.predict, audio=io.BytesIO(e.content.read()))
+    prediction = await run.io_bound(version.predict, audio=io.BytesIO(e.content.read()))
     text = prediction.get('transcription', 'no transcription')
     text = prediction.get('transcription', 'no transcription')
     transcription.set_text(f'result: "{text}"')
     transcription.set_text(f'result: "{text}"')
 
 
@@ -28,7 +20,7 @@ async def generate_image():
     image.source = 'https://dummyimage.com/600x400/ccc/000000.png&text=building+image...'
     image.source = 'https://dummyimage.com/600x400/ccc/000000.png&text=building+image...'
     model = replicate.models.get('stability-ai/stable-diffusion')
     model = replicate.models.get('stability-ai/stable-diffusion')
     version = model.versions.get('db21e45d3f7023abc2a46ee38a23973f6dce16bb082a930b0c49861f96d1e5bf')
     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]
     image.source = prediction[0]
 
 
 # User Interface
 # User Interface

+ 32 - 5
examples/authentication/main.py

@@ -1,33 +1,60 @@
 #!/usr/bin/env python3
 #!/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
 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.
 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.
 Here we just demonstrate the NiceGUI integration.
 """
 """
+from typing import Optional
+
+from fastapi import Request
 from fastapi.responses import RedirectResponse
 from fastapi.responses import RedirectResponse
+from starlette.middleware.base import BaseHTTPMiddleware
 
 
+import nicegui.globals
 from nicegui import app, ui
 from nicegui import app, ui
 
 
 # in reality users passwords would obviously need to be hashed
 # in reality users passwords would obviously need to be hashed
 passwords = {'user1': 'pass1', 'user2': 'pass2'}
 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('/')
 @ui.page('/')
 def main_page() -> None:
 def main_page() -> None:
-    if not app.storage.user.get('authenticated', False):
-        return RedirectResponse('/login')
     with ui.column().classes('absolute-center items-center'):
     with ui.column().classes('absolute-center items-center'):
         ui.label(f'Hello {app.storage.user["username"]}!').classes('text-2xl')
         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.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')
 @ui.page('/login')
-def login() -> None:
+def login() -> Optional[RedirectResponse]:
     def try_login() -> None:  # local function to avoid passing username and password as arguments
     def try_login() -> None:  # local function to avoid passing username and password as arguments
         if passwords.get(username.value) == password.value:
         if passwords.get(username.value) == password.value:
             app.storage.user.update({'username': username.value, 'authenticated': True})
             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:
         else:
             ui.notify('Wrong username or password', color='negative')
             ui.notify('Wrong username or password', color='negative')
 
 

+ 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()

+ 3 - 10
examples/opencv_webcam/main.py

@@ -1,7 +1,5 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
-import asyncio
 import base64
 import base64
-import concurrent.futures
 import signal
 import signal
 import time
 import time
 
 
@@ -10,10 +8,8 @@ import numpy as np
 from fastapi import Response
 from fastapi import Response
 
 
 import nicegui.globals
 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.
 # In case you don't have a webcam, this will provide a black placeholder image.
 black_1px = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjYGBg+A8AAQQBAHAgZQsAAAAASUVORK5CYII='
 black_1px = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjYGBg+A8AAQQBAHAgZQsAAAAASUVORK5CYII='
 placeholder = Response(content=base64.b64decode(black_1px.encode('ascii')), media_type='image/png')
 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:
 async def grab_video_frame() -> Response:
     if not video_capture.isOpened():
     if not video_capture.isOpened():
         return placeholder
         return placeholder
-    loop = asyncio.get_running_loop()
     # The `video_capture.read` call is a blocking function.
     # 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.
     # 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:
     if frame is None:
         return placeholder
         return placeholder
     # `convert` is a CPU-intensive function, so we run it in a separate process to avoid blocking the event loop and GIL.
     # `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')
     return Response(content=jpeg, media_type='image/jpeg')
 
 
 # For non-flickering image updates an interactive image is much better than `ui.image()`.
 # For non-flickering image updates an interactive image is much better than `ui.image()`.
@@ -68,8 +63,6 @@ async def cleanup() -> None:
     await disconnect()
     await disconnect()
     # Release the webcam hardware so it can be used by other applications again.
     # Release the webcam hardware so it can be used by other applications again.
     video_capture.release()
     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)
 app.on_shutdown(cleanup)
 # We also need to disconnect clients when the app is stopped with Ctrl+C,
 # 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
 #!/usr/bin/env python3
-import asyncio
 import time
 import time
-from concurrent.futures import ProcessPoolExecutor
 from multiprocessing import Manager, Queue
 from multiprocessing import Manager, Queue
 
 
-from nicegui import app, ui
-
-pool = ProcessPoolExecutor()
+from nicegui import run, ui
 
 
 
 
 def heavy_computation(q: Queue) -> str:
 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
     n = 50
     for i in range(n):
     for i in range(n):
         # Perform some heavy computation
         # Perform some heavy computation
@@ -23,11 +19,9 @@ def heavy_computation(q: Queue) -> str:
 
 
 @ui.page('/')
 @ui.page('/')
 def main_page():
 def main_page():
-
     async def start_computation():
     async def start_computation():
         progressbar.visible = True
         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)
         ui.notify(result)
         progressbar.visible = False
         progressbar.visible = False
 
 
@@ -42,7 +36,4 @@ def main_page():
     progressbar.visible = False
     progressbar.visible = False
 
 
 
 
-# stop the pool when the app is closed; will not cancel any running tasks
-app.on_shutdown(pool.shutdown)
-
 ui.run()
 ui.run()

+ 2 - 0
examples/slideshow/main.py

@@ -4,6 +4,8 @@ from pathlib import Path
 from nicegui import app, ui
 from nicegui import app, ui
 from nicegui.events import KeyEventArguments
 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/
 folder = Path(__file__).parent / 'slides'  # image source: https://pixabay.com/
 files = sorted(f.name for f in folder.glob('*.jpg'))
 files = sorted(f.name for f in folder.glob('*.jpg'))
 index = 0
 index = 0

+ 1 - 0
main.py

@@ -349,6 +349,7 @@ async def index_page(client: Client) -> None:
                          '[zauberzeug/nicegui](https://hub.docker.com/r/zauberzeug/nicegui) docker image')
                          '[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('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('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')
 
 
     with ui.row().classes('dark-box min-h-screen mt-16'):
     with ui.row().classes('dark-box min-h-screen mt-16'):
         link_target('why')
         link_target('why')

+ 4 - 1
nicegui/__init__.py

@@ -1,4 +1,6 @@
-from . import elements, globals, ui  # pylint: disable=redefined-builtin
+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 .api_router import APIRouter
 from .client import Client
 from .client import Client
 from .nicegui import app
 from .nicegui import app
@@ -11,6 +13,7 @@ __all__ = [
     'Client',
     'Client',
     'elements',
     'elements',
     'globals',
     'globals',
+    'run',
     'Tailwind',
     'Tailwind',
     'ui',
     'ui',
     '__version__',
     '__version__',

+ 3 - 4
nicegui/air.py

@@ -43,18 +43,17 @@ class Air:
             if match:
             if match:
                 new_js_object = match.group(1).decode().rstrip('}') + ", 'fly_instance_id' : '" + instance_id + "'}"
                 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())
                 content = content.replace(match.group(0), f'const query = {new_js_object}'.encode())
-            response_headers = dict(response.headers)
-            response_headers['content-encoding'] = 'gzip'
             compressed = gzip.compress(content)
             compressed = gzip.compress(content)
-            response_headers['content-length'] = str(len(compressed))
+            response.headers.update({'content-encoding': 'gzip', 'content-length': str(len(compressed))})
             return {
             return {
                 'status_code': response.status_code,
                 'status_code': response.status_code,
-                'headers': response_headers,
+                'headers': response.headers.multi_items(),
                 'content': compressed,
                 'content': compressed,
             }
             }
 
 
         @self.relay.on('ready')
         @self.relay.on('ready')
         def on_ready(data: Dict[str, Any]) -> None:
         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)
             print(f'NiceGUI is on air at {data["device_url"]}', flush=True)
 
 
         @self.relay.on('error')
         @self.relay.on('error')

+ 2 - 0
nicegui/app.py

@@ -7,6 +7,7 @@ from fastapi.staticfiles import StaticFiles
 
 
 from . import globals, helpers  # pylint: disable=redefined-builtin
 from . import globals, helpers  # pylint: disable=redefined-builtin
 from .native import Native
 from .native import Native
+from .observables import ObservableSet
 from .storage import Storage
 from .storage import Storage
 
 
 
 
@@ -16,6 +17,7 @@ class App(FastAPI):
         super().__init__(**kwargs)
         super().__init__(**kwargs)
         self.native = Native()
         self.native = Native()
         self.storage = Storage()
         self.storage = Storage()
+        self.urls = ObservableSet()
 
 
     def on_connect(self, handler: Union[Callable, Awaitable]) -> None:
     def on_connect(self, handler: Union[Callable, Awaitable]) -> None:
         """Called every time a new client connects to NiceGUI.
         """Called every time a new client connects to NiceGUI.

+ 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')

+ 9 - 12
nicegui/elements/echart.js

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

+ 31 - 2
nicegui/elements/echart.py

@@ -1,11 +1,12 @@
-from typing import Dict
+from typing import Callable, Dict, Optional
 
 
 from ..element import Element
 from ..element import Element
+from ..events import EChartPointClickEventArguments, GenericEventArguments, handle_event
 
 
 
 
 class EChart(Element, component='echart.js', libraries=['lib/echarts/echarts.min.js']):
 class EChart(Element, component='echart.js', libraries=['lib/echarts/echarts.min.js']):
 
 
-    def __init__(self, options: Dict) -> None:
+    def __init__(self, options: Dict, on_point_click: Optional[Callable] = None) -> None:
         """Apache EChart
         """Apache EChart
 
 
         An element to create a chart using `ECharts <https://echarts.apache.org/>`_.
         An element to create a chart using `ECharts <https://echarts.apache.org/>`_.
@@ -13,11 +14,39 @@ class EChart(Element, component='echart.js', libraries=['lib/echarts/echarts.min
         After data has changed, call the `update` method to refresh the chart.
         After data has changed, call the `update` method to refresh the chart.
 
 
         :param options: dictionary of EChart options
         :param options: dictionary of EChart options
+        :param on_click_point: callback function that is called when a point is clicked
         """
         """
         super().__init__()
         super().__init__()
         self._props['options'] = options
         self._props['options'] = options
         self._classes = ['nicegui-echart']
         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
     @property
     def options(self) -> Dict:
     def options(self) -> Dict:
         return self._props['options']
         return self._props['options']

+ 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

+ 21 - 11
nicegui/elements/interactive_image.js

@@ -1,7 +1,15 @@
 export default {
 export default {
   template: `
   template: `
     <div style="position:relative">
     <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">
       <svg style="position:absolute;top:0;left:0;pointer-events:none" :viewBox="viewBox">
         <g v-if="cross" :style="{ display: cssDisplay }">
         <g v-if="cross" :style="{ display: cssDisplay }">
           <line :x1="x" y1="0" :x2="x" y2="100%" stroke="black" />
           <line :x1="x" y1="0" :x2="x" y2="100%" stroke="black" />
@@ -74,18 +82,20 @@ export default {
     },
     },
   },
   },
   computed: {
   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) {
       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: {
   props: {

+ 7 - 0
nicegui/elements/stepper.py

@@ -13,16 +13,23 @@ class Stepper(ValueElement):
     def __init__(self, *,
     def __init__(self, *,
                  value: Union[str, Step, None] = None,
                  value: Union[str, Step, None] = None,
                  on_value_change: Optional[Callable[..., Any]] = None,
                  on_value_change: Optional[Callable[..., Any]] = None,
+                 keep_alive: bool = True,
                  ) -> None:
                  ) -> None:
         """Stepper
         """Stepper
 
 
         This element represents `Quasar's QStepper <https://quasar.dev/vue-components/stepper#qstepper-api>`_ component.
         This element represents `Quasar's QStepper <https://quasar.dev/vue-components/stepper#qstepper-api>`_ component.
         It contains individual steps.
         It contains individual steps.
 
 
+        To avoid issues with dynamic elements when switching steps,
+        this element uses Vue's `keep-alive <https://vuejs.org/guide/built-ins/keep-alive.html>`_ component.
+        If client-side performance is an issue, you can disable this feature.
+
         :param value: `ui.step` or name of the step to be initially selected (default: `None` meaning the first step)
         :param value: `ui.step` or name of the step to be initially selected (default: `None` meaning the first step)
         :param on_value_change: callback to be executed when the selected step changes
         :param on_value_change: callback to be executed when the selected step changes
+        :param keep_alive: whether to use Vue's keep-alive component on the content (default: `True`)
         """
         """
         super().__init__(tag='q-stepper', value=value, on_value_change=on_value_change)
         super().__init__(tag='q-stepper', value=value, on_value_change=on_value_change)
+        self._props['keep-alive'] = keep_alive
 
 
     def _value_to_model_value(self, value: Any) -> Any:
     def _value_to_model_value(self, value: Any) -> Any:
         return value._props['name'] if isinstance(value, Step) else value  # pylint: disable=protected-access
         return value._props['name'] if isinstance(value, Step) else value  # pylint: disable=protected-access

+ 4 - 4
nicegui/elements/table.py

@@ -1,4 +1,4 @@
-from typing import Any, Callable, Dict, List, Literal, Optional
+from typing import Any, Callable, Dict, List, Literal, Optional, Union
 
 
 from ..element import Element
 from ..element import Element
 from ..events import GenericEventArguments, TableSelectionEventArguments, handle_event
 from ..events import GenericEventArguments, TableSelectionEventArguments, handle_event
@@ -13,7 +13,7 @@ class Table(FilterElement, component='table.js'):
                  row_key: str = 'id',
                  row_key: str = 'id',
                  title: Optional[str] = None,
                  title: Optional[str] = None,
                  selection: Optional[Literal['single', 'multiple']] = None,
                  selection: Optional[Literal['single', 'multiple']] = None,
-                 pagination: Optional[int] = None,
+                 pagination: Optional[Union[int, dict]] = None,
                  on_select: Optional[Callable[..., Any]] = None,
                  on_select: Optional[Callable[..., Any]] = None,
                  ) -> None:
                  ) -> None:
         """Table
         """Table
@@ -25,7 +25,7 @@ class Table(FilterElement, component='table.js'):
         :param row_key: name of the column containing unique data identifying the row (default: "id")
         :param row_key: name of the column containing unique data identifying the row (default: "id")
         :param title: title of the table
         :param title: title of the table
         :param selection: selection type ("single" or "multiple"; default: `None`)
         :param selection: selection type ("single" or "multiple"; default: `None`)
-        :param pagination: number of rows per page (`None` hides the pagination, 0 means "infinite"; default: `None`)
+        :param pagination: A dictionary correlating to a pagination object or number of rows per page (`None` hides the pagination, 0 means "infinite"; default: `None`).
         :param on_select: callback which is invoked when the selection changes
         :param on_select: callback which is invoked when the selection changes
 
 
         If selection is 'single' or 'multiple', then a `selected` property is accessible containing the selected rows.
         If selection is 'single' or 'multiple', then a `selected` property is accessible containing the selected rows.
@@ -41,7 +41,7 @@ class Table(FilterElement, component='table.js'):
         self._props['row-key'] = row_key
         self._props['row-key'] = row_key
         self._props['title'] = title
         self._props['title'] = title
         self._props['hide-pagination'] = pagination is None
         self._props['hide-pagination'] = pagination is None
-        self._props['pagination'] = {'rowsPerPage': pagination or 0}
+        self._props['pagination'] = pagination if isinstance(pagination, dict) else {'rowsPerPage': pagination or 0}
         self._props['selection'] = selection or 'none'
         self._props['selection'] = selection or 'none'
         self._props['selected'] = self.selected
         self._props['selected'] = self.selected
         self._props['fullscreen'] = False
         self._props['fullscreen'] = False

+ 73 - 0
nicegui/elements/timeline.py

@@ -0,0 +1,73 @@
+from typing import Literal, Optional
+
+from nicegui.element import Element
+
+
+class Timeline(Element):
+
+    def __init__(self,
+                 *,
+                 side: Literal['left', 'right'] = 'left',
+                 layout: Literal['dense', 'comfortable', 'loose'] = 'dense',
+                 color: Optional[str] = None,
+                 ) -> None:
+        """Timeline
+
+        This element represents `Quasar's QTimeline <https://quasar.dev/vue-components/timeline#qtimeline-api>`_ component.
+
+        :param side: Side ("left" or "right"; default: "left").
+        :param layout: Layout ("dense", "comfortable" or "loose"; default: "dense").
+        :param color: Color of the icons.
+        """
+        super().__init__('q-timeline')
+        self._props['side'] = side
+        self._props['layout'] = layout
+        if color is not None:
+            self._props['color'] = color
+
+
+class TimelineEntry(Element):
+
+    def __init__(self,
+                 body: Optional[str] = None,
+                 *,
+                 side: Literal['left', 'right'] = 'left',
+                 heading: bool = False,
+                 tag: Optional[str] = None,
+                 icon: Optional[str] = None,
+                 avatar: Optional[str] = None,
+                 title: Optional[str] = None,
+                 subtitle: Optional[str] = None,
+                 color: Optional[str] = None,
+                 ) -> None:
+        """Timeline Entry
+
+        This element represents `Quasar's QTimelineEntry <https://quasar.dev/vue-components/timeline#qtimelineentry-api>`_ component.
+
+        :param body: Body text.
+        :param side: Side ("left" or "right"; default: "left").
+        :param heading: Whether the timeline entry is a heading.
+        :param tag: HTML tag name to be used if it is a heading.
+        :param icon: Icon name.
+        :param avatar: Avatar URL.
+        :param title: Title text.
+        :param subtitle: Subtitle text.
+        :param color: Color or the timeline.
+        """
+        super().__init__('q-timeline-entry')
+        if body is not None:
+            self._props['body'] = body
+        self._props['side'] = side
+        self._props['heading'] = heading
+        if tag is not None:
+            self._props['tag'] = tag
+        if color is not None:
+            self._props['color'] = color
+        if icon is not None:
+            self._props['icon'] = icon
+        if avatar is not None:
+            self._props['avatar'] = avatar
+        if title is not None:
+            self._props['title'] = title
+        if subtitle is not None:
+            self._props['subtitle'] = subtitle

+ 4 - 1
nicegui/elements/toggle.py

@@ -11,6 +11,7 @@ class Toggle(ChoiceElement, DisableableElement):
                  options: Union[List, Dict], *,
                  options: Union[List, Dict], *,
                  value: Any = None,
                  value: Any = None,
                  on_change: Optional[Callable[..., Any]] = None,
                  on_change: Optional[Callable[..., Any]] = None,
+                 clearable: bool = False,
                  ) -> None:
                  ) -> None:
         """Toggle
         """Toggle
 
 
@@ -20,11 +21,13 @@ class Toggle(ChoiceElement, DisableableElement):
         :param options: a list ['value1', ...] or dictionary `{'value1':'label1', ...}` specifying the options
         :param options: a list ['value1', ...] or dictionary `{'value1':'label1', ...}` specifying the options
         :param value: the initial value
         :param value: the initial value
         :param on_change: callback to execute when selection changes
         :param on_change: callback to execute when selection changes
+        :param clearable: whether the toggle can be cleared by clicking the selected option
         """
         """
         super().__init__(tag='q-btn-toggle', options=options, value=value, on_change=on_change)
         super().__init__(tag='q-btn-toggle', options=options, value=value, on_change=on_change)
+        self._props['clearable'] = clearable
 
 
     def _event_args_to_value(self, e: GenericEventArguments) -> Any:
     def _event_args_to_value(self, e: GenericEventArguments) -> Any:
-        return self._values[e.args]
+        return self._values[e.args] if e.args is not None else None
 
 
     def _value_to_model_value(self, value: Any) -> Any:
     def _value_to_model_value(self, value: Any) -> Any:
         return self._values.index(value) if value in self._values else None
         return self._values.index(value) if value in self._values else None

+ 15 - 2
nicegui/events.py

@@ -12,7 +12,7 @@ from .slot import Slot
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from .client import Client
     from .client import Client
     from .element import Element
     from .element import Element
-    from .observables import ObservableDict, ObservableList, ObservableSet
+    from .observables import ObservableCollection
 
 
 
 
 @dataclass(**KWONLY_SLOTS)
 @dataclass(**KWONLY_SLOTS)
@@ -22,7 +22,7 @@ class EventArguments:
 
 
 @dataclass(**KWONLY_SLOTS)
 @dataclass(**KWONLY_SLOTS)
 class ObservableChangeEventArguments(EventArguments):
 class ObservableChangeEventArguments(EventArguments):
-    sender: Union[ObservableDict, ObservableList, ObservableSet]
+    sender: ObservableCollection
 
 
 
 
 @dataclass(**KWONLY_SLOTS)
 @dataclass(**KWONLY_SLOTS)
@@ -53,6 +53,19 @@ class ChartEventArguments(UiEventArguments):
     event_type: str
     event_type: str
 
 
 
 
+@dataclass(**KWONLY_SLOTS)
+class EChartPointClickEventArguments(UiEventArguments):
+    component_type: str
+    series_type: str
+    series_index: int
+    series_name: str
+    name: str
+    data_index: int
+    data: Union[float, int, str]
+    data_type: str
+    value: Union[float, int, list]
+
+
 @dataclass(**KWONLY_SLOTS)
 @dataclass(**KWONLY_SLOTS)
 class ChartPointClickEventArguments(ChartEventArguments):
 class ChartPointClickEventArguments(ChartEventArguments):
     series_index: int
     series_index: int

+ 2 - 2
nicegui/native.py

@@ -1,4 +1,3 @@
-import asyncio
 import inspect
 import inspect
 import warnings
 import warnings
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
@@ -8,6 +7,7 @@ from typing import Any, Callable, Dict, Optional, Tuple
 
 
 from .dataclasses import KWONLY_SLOTS
 from .dataclasses import KWONLY_SLOTS
 from .globals import log
 from .globals import log
+from .run_executor import io_bound
 
 
 method_queue: Queue = Queue()
 method_queue: Queue = Queue()
 response_queue: Queue = Queue()
 response_queue: Queue = Queue()
@@ -123,7 +123,7 @@ try:
                     log.exception(f'error in {name}')
                     log.exception(f'error in {name}')
                     return None
                     return None
             name = inspect.currentframe().f_back.f_code.co_name  # type: ignore
             name = inspect.currentframe().f_back.f_code.co_name  # type: ignore
-            return await asyncio.get_event_loop().run_in_executor(None, partial(wrapper, *args, **kwargs))
+            return await io_bound(wrapper, *args, **kwargs)
 
 
         def signal_server_shutdown(self) -> None:
         def signal_server_shutdown(self) -> None:
             self._send()
             self._send()

+ 7 - 1
nicegui/nicegui.py

@@ -1,4 +1,5 @@
 import asyncio
 import asyncio
+import mimetypes
 import time
 import time
 import urllib.parse
 import urllib.parse
 from pathlib import Path
 from pathlib import Path
@@ -10,7 +11,8 @@ from fastapi.responses import FileResponse, Response
 from fastapi.staticfiles import StaticFiles
 from fastapi.staticfiles import StaticFiles
 from fastapi_socketio import SocketManager
 from fastapi_socketio import SocketManager
 
 
-from . import background_tasks, binding, favicon, globals, json, outbox, welcome  # pylint: disable=redefined-builtin
+from . import (background_tasks, binding, favicon, globals, json, outbox,  # pylint: disable=redefined-builtin
+               run_executor, welcome)
 from .app import App
 from .app import App
 from .client import Client
 from .client import Client
 from .dependencies import js_components, libraries
 from .dependencies import js_components, libraries
@@ -26,6 +28,9 @@ globals.app = app = App(default_response_class=NiceGUIJSONResponse)
 socket_manager = SocketManager(app=app, mount_location='/_nicegui_ws/', json=json)
 socket_manager = SocketManager(app=app, mount_location='/_nicegui_ws/', json=json)
 globals.sio = sio = socket_manager._sio  # pylint: disable=protected-access
 globals.sio = sio = socket_manager._sio  # pylint: disable=protected-access
 
 
+mimetypes.add_type('text/javascript', '.js')
+mimetypes.add_type('text/css', '.css')
+
 app.add_middleware(GZipMiddleware)
 app.add_middleware(GZipMiddleware)
 app.add_middleware(RedirectWithPrefixMiddleware)
 app.add_middleware(RedirectWithPrefixMiddleware)
 static_files = StaticFiles(
 static_files = StaticFiles(
@@ -105,6 +110,7 @@ async def handle_shutdown() -> None:
     with globals.index_client:
     with globals.index_client:
         for t in globals.shutdown_handlers:
         for t in globals.shutdown_handlers:
             safe_invoke(t)
             safe_invoke(t)
+    run_executor.tear_down()
     globals.state = globals.State.STOPPED
     globals.state = globals.State.STOPPED
     if globals.air:
     if globals.air:
         await globals.air.disconnect()
         await globals.air.disconnect()

+ 117 - 89
nicegui/observables.py

@@ -1,212 +1,240 @@
-from typing import Any, Callable, Dict, Iterable, List, Set, SupportsIndex, Union, overload
+from __future__ import annotations
 
 
-from . import events
+import abc
+from typing import Any, Callable, Collection, Dict, Iterable, List, Optional, SupportsIndex, Union
 
 
+from . import events
 
 
-class ObservableDict(dict):
 
 
-    def __init__(self, data: Dict, on_change: Callable) -> None:
-        super().__init__(data)
+class ObservableCollection(abc.ABC):
+
+    def __init__(self, *,
+                 factory: Callable,
+                 data: Optional[Collection],
+                 on_change: Optional[Callable],
+                 _parent: Optional[ObservableCollection],
+                 ) -> None:
+        super().__init__(factory() if data is None else data)  # type: ignore
+        self._parent = _parent
+        self._change_handlers: List[Callable] = [on_change] if on_change else []
+
+    @property
+    def change_handlers(self) -> List[Callable]:
+        """Return a list of all change handlers registered on this collection and its parents."""
+        change_handlers = self._change_handlers[:]
+        if self._parent is not None:
+            change_handlers.extend(self._parent.change_handlers)
+        return change_handlers
+
+    def _handle_change(self) -> None:
+        for handler in self.change_handlers:
+            events.handle_event(handler, events.ObservableChangeEventArguments(sender=self))
+
+    def on_change(self, handler: Callable) -> None:
+        """Register a handler to be called when the collection changes."""
+        self._change_handlers.append(handler)
+
+    def _observe(self, data: Any) -> Any:
+        if isinstance(data, dict):
+            return ObservableDict(data, _parent=self)
+        if isinstance(data, list):
+            return ObservableList(data, _parent=self)
+        if isinstance(data, set):
+            return ObservableSet(data, _parent=self)
+        return data
+
+
+class ObservableDict(ObservableCollection, dict):
+
+    def __init__(self,
+                 data: Dict = None,  # type: ignore
+                 *,
+                 on_change: Optional[Callable] = None,
+                 _parent: Optional[ObservableCollection] = None,
+                 ) -> None:
+        super().__init__(factory=dict, data=data, on_change=on_change, _parent=_parent)
         for key, value in self.items():
         for key, value in self.items():
-            super().__setitem__(key, make_observable(value, on_change))
-        self.on_change = lambda: events.handle_event(on_change, events.ObservableChangeEventArguments(sender=self))
+            super().__setitem__(key, self._observe(value))
 
 
     def pop(self, k: Any, d: Any = None) -> Any:
     def pop(self, k: Any, d: Any = None) -> Any:
         item = super().pop(k, d)
         item = super().pop(k, d)
-        self.on_change()
+        self._handle_change()
         return item
         return item
 
 
     def popitem(self) -> Any:
     def popitem(self) -> Any:
         item = super().popitem()
         item = super().popitem()
-        self.on_change()
+        self._handle_change()
         return item
         return item
 
 
     def update(self, *args: Any, **kwargs: Any) -> None:
     def update(self, *args: Any, **kwargs: Any) -> None:
-        super().update(make_observable(dict(*args, **kwargs), self.on_change))
-        self.on_change()
+        super().update(self._observe(dict(*args, **kwargs)))
+        self._handle_change()
 
 
     def clear(self) -> None:
     def clear(self) -> None:
         super().clear()
         super().clear()
-        self.on_change()
+        self._handle_change()
 
 
     def setdefault(self, __key: Any, __default: Any = None) -> Any:
     def setdefault(self, __key: Any, __default: Any = None) -> Any:
-        item = super().setdefault(__key, make_observable(__default, self.on_change))
-        self.on_change()
+        item = super().setdefault(__key, self._observe(__default))
+        self._handle_change()
         return item
         return item
 
 
     def __setitem__(self, __key: Any, __value: Any) -> None:
     def __setitem__(self, __key: Any, __value: Any) -> None:
-        super().__setitem__(__key, make_observable(__value, self.on_change))
-        self.on_change()
+        super().__setitem__(__key, self._observe(__value))
+        self._handle_change()
 
 
     def __delitem__(self, __key: Any) -> None:
     def __delitem__(self, __key: Any) -> None:
         super().__delitem__(__key)
         super().__delitem__(__key)
-        self.on_change()
+        self._handle_change()
 
 
     def __or__(self, other: Any) -> Any:
     def __or__(self, other: Any) -> Any:
         return super().__or__(other)
         return super().__or__(other)
 
 
     def __ior__(self, other: Any) -> Any:
     def __ior__(self, other: Any) -> Any:
-        super().__ior__(make_observable(dict(other), self.on_change))
-        self.on_change()
+        super().__ior__(self._observe(dict(other)))
+        self._handle_change()
         return self
         return self
 
 
 
 
-class ObservableList(list):
+class ObservableList(ObservableCollection, list):
 
 
-    def __init__(self, data: List, on_change: Callable) -> None:
-        super().__init__(data)
+    def __init__(self,
+                 data: List = None,  # type: ignore
+                 *,
+                 on_change: Optional[Callable] = None,
+                 _parent: Optional[ObservableCollection] = None,
+                 ) -> None:
+        super().__init__(factory=list, data=data, on_change=on_change, _parent=_parent)
         for i, item in enumerate(self):
         for i, item in enumerate(self):
-            super().__setitem__(i, make_observable(item, on_change))
-        self.on_change = lambda: events.handle_event(on_change, events.ObservableChangeEventArguments(sender=self))
+            super().__setitem__(i, self._observe(item))
 
 
     def append(self, item: Any) -> None:
     def append(self, item: Any) -> None:
-        super().append(make_observable(item, self.on_change))
-        self.on_change()
+        super().append(self._observe(item))
+        self._handle_change()
 
 
     def extend(self, iterable: Iterable) -> None:
     def extend(self, iterable: Iterable) -> None:
-        super().extend(make_observable(list(iterable), self.on_change))
-        self.on_change()
+        super().extend(self._observe(list(iterable)))
+        self._handle_change()
 
 
     def insert(self, index: SupportsIndex, obj: Any) -> None:
     def insert(self, index: SupportsIndex, obj: Any) -> None:
-        super().insert(index, make_observable(obj, self.on_change))
-        self.on_change()
+        super().insert(index, self._observe(obj))
+        self._handle_change()
 
 
     def remove(self, value: Any) -> None:
     def remove(self, value: Any) -> None:
         super().remove(value)
         super().remove(value)
-        self.on_change()
+        self._handle_change()
 
 
     def pop(self, index: SupportsIndex = -1) -> Any:
     def pop(self, index: SupportsIndex = -1) -> Any:
         item = super().pop(index)
         item = super().pop(index)
-        self.on_change()
+        self._handle_change()
         return item
         return item
 
 
     def clear(self) -> None:
     def clear(self) -> None:
         super().clear()
         super().clear()
-        self.on_change()
+        self._handle_change()
 
 
     def sort(self, **kwargs: Any) -> None:
     def sort(self, **kwargs: Any) -> None:
         super().sort(**kwargs)
         super().sort(**kwargs)
-        self.on_change()
+        self._handle_change()
 
 
     def reverse(self) -> None:
     def reverse(self) -> None:
         super().reverse()
         super().reverse()
-        self.on_change()
+        self._handle_change()
 
 
     def __delitem__(self, key: Union[SupportsIndex, slice]) -> None:
     def __delitem__(self, key: Union[SupportsIndex, slice]) -> None:
         super().__delitem__(key)
         super().__delitem__(key)
-        self.on_change()
+        self._handle_change()
 
 
     def __setitem__(self, key: Union[SupportsIndex, slice], value: Any) -> None:
     def __setitem__(self, key: Union[SupportsIndex, slice], value: Any) -> None:
-        super().__setitem__(key, make_observable(value, self.on_change))
-        self.on_change()
+        super().__setitem__(key, self._observe(value))
+        self._handle_change()
 
 
     def __add__(self, other: Any) -> Any:
     def __add__(self, other: Any) -> Any:
         return super().__add__(other)
         return super().__add__(other)
 
 
     def __iadd__(self, other: Any) -> Any:
     def __iadd__(self, other: Any) -> Any:
-        super().__iadd__(make_observable(other, self.on_change))
-        self.on_change()
+        super().__iadd__(self._observe(other))
+        self._handle_change()
         return self
         return self
 
 
 
 
-class ObservableSet(set):
+class ObservableSet(ObservableCollection, set):
 
 
-    def __init__(self, data: set, on_change: Callable) -> None:
-        super().__init__(data)
+    def __init__(self,
+                 data: set = None,  # type: ignore
+                 *,
+                 on_change: Optional[Callable] = None,
+                 _parent: Optional[ObservableCollection] = None,
+                 ) -> None:
+        super().__init__(factory=set, data=data, on_change=on_change, _parent=_parent)
         for item in self:
         for item in self:
-            super().add(make_observable(item, on_change))
-        self.on_change = lambda: events.handle_event(on_change, events.ObservableChangeEventArguments(sender=self))
+            super().add(self._observe(item))
 
 
     def add(self, item: Any) -> None:
     def add(self, item: Any) -> None:
-        super().add(make_observable(item, self.on_change))
-        self.on_change()
+        super().add(self._observe(item))
+        self._handle_change()
 
 
     def remove(self, item: Any) -> None:
     def remove(self, item: Any) -> None:
         super().remove(item)
         super().remove(item)
-        self.on_change()
+        self._handle_change()
 
 
     def discard(self, item: Any) -> None:
     def discard(self, item: Any) -> None:
         super().discard(item)
         super().discard(item)
-        self.on_change()
+        self._handle_change()
 
 
     def pop(self) -> Any:
     def pop(self) -> Any:
         item = super().pop()
         item = super().pop()
-        self.on_change()
+        self._handle_change()
         return item
         return item
 
 
     def clear(self) -> None:
     def clear(self) -> None:
         super().clear()
         super().clear()
-        self.on_change()
+        self._handle_change()
 
 
     def update(self, *s: Iterable[Any]) -> None:
     def update(self, *s: Iterable[Any]) -> None:
-        super().update(make_observable(set(*s), self.on_change))
-        self.on_change()
+        super().update(self._observe(set(*s)))
+        self._handle_change()
 
 
     def intersection_update(self, *s: Iterable[Any]) -> None:
     def intersection_update(self, *s: Iterable[Any]) -> None:
         super().intersection_update(*s)
         super().intersection_update(*s)
-        self.on_change()
+        self._handle_change()
 
 
     def difference_update(self, *s: Iterable[Any]) -> None:
     def difference_update(self, *s: Iterable[Any]) -> None:
         super().difference_update(*s)
         super().difference_update(*s)
-        self.on_change()
+        self._handle_change()
 
 
     def symmetric_difference_update(self, *s: Iterable[Any]) -> None:
     def symmetric_difference_update(self, *s: Iterable[Any]) -> None:
         super().symmetric_difference_update(*s)
         super().symmetric_difference_update(*s)
-        self.on_change()
+        self._handle_change()
 
 
     def __or__(self, other: Any) -> Any:
     def __or__(self, other: Any) -> Any:
         return super().__or__(other)
         return super().__or__(other)
 
 
     def __ior__(self, other: Any) -> Any:
     def __ior__(self, other: Any) -> Any:
-        super().__ior__(make_observable(other, self.on_change))
-        self.on_change()
+        super().__ior__(self._observe(other))
+        self._handle_change()
         return self
         return self
 
 
     def __and__(self, other: Any) -> set:
     def __and__(self, other: Any) -> set:
         return super().__and__(other)
         return super().__and__(other)
 
 
     def __iand__(self, other: Any) -> Any:
     def __iand__(self, other: Any) -> Any:
-        super().__iand__(make_observable(other, self.on_change))
-        self.on_change()
+        super().__iand__(self._observe(other))
+        self._handle_change()
         return self
         return self
 
 
     def __sub__(self, other: Any) -> set:
     def __sub__(self, other: Any) -> set:
         return super().__sub__(other)
         return super().__sub__(other)
 
 
     def __isub__(self, other: Any) -> Any:
     def __isub__(self, other: Any) -> Any:
-        super().__isub__(make_observable(other, self.on_change))
-        self.on_change()
+        super().__isub__(self._observe(other))
+        self._handle_change()
         return self
         return self
 
 
     def __xor__(self, other: Any) -> set:
     def __xor__(self, other: Any) -> set:
         return super().__xor__(other)
         return super().__xor__(other)
 
 
     def __ixor__(self, other: Any) -> Any:
     def __ixor__(self, other: Any) -> Any:
-        super().__ixor__(make_observable(other, self.on_change))
-        self.on_change()
+        super().__ixor__(self._observe(other))
+        self._handle_change()
         return self
         return self
-
-
-@overload
-def make_observable(data: Dict, on_change: Callable) -> ObservableDict:
-    ...
-
-
-@overload
-def make_observable(data: List, on_change: Callable) -> ObservableList:
-    ...
-
-
-@overload
-def make_observable(data: Set, on_change: Callable) -> ObservableSet:
-    ...
-
-
-def make_observable(data: Any, on_change: Callable) -> Any:
-    if isinstance(data, dict):
-        return ObservableDict(data, on_change)
-    if isinstance(data, list):
-        return ObservableList(data, on_change)
-    if isinstance(data, set):
-        return ObservableSet(data, on_change)
-    return data

+ 44 - 0
nicegui/run_executor.py

@@ -0,0 +1,44 @@
+import asyncio
+import sys
+from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
+from functools import partial
+from typing import Any, Callable
+
+from . import globals, helpers  # pylint: disable=redefined-builtin
+
+process_pool = ProcessPoolExecutor()
+thread_pool = ThreadPoolExecutor()
+
+
+async def _run(executor: Any, callback: Callable, *args: Any, **kwargs: Any) -> Any:
+    if globals.state == globals.State.STOPPING:
+        return
+    try:
+        loop = asyncio.get_running_loop()
+        return await loop.run_in_executor(executor, partial(callback, *args, **kwargs))
+    except RuntimeError as e:
+        if 'cannot schedule new futures after shutdown' not in str(e):
+            raise
+    except asyncio.exceptions.CancelledError:
+        pass
+
+
+async def cpu_bound(callback: Callable, *args: Any, **kwargs: Any) -> Any:
+    """Run a CPU-bound function in a separate process."""
+    return await _run(process_pool, callback, *args, **kwargs)
+
+
+async def io_bound(callback: Callable, *args: Any, **kwargs: Any) -> Any:
+    """Run an I/O-bound function in a separate thread."""
+    return await _run(thread_pool, callback, *args, **kwargs)
+
+
+def tear_down() -> None:
+    """Kill all processes and threads."""
+    if helpers.is_pytest():
+        return
+    for p in process_pool._processes.values():  # pylint: disable=protected-access
+        p.kill()
+    kwargs = {'cancel_futures': True} if sys.version_info >= (3, 9) else {}
+    process_pool.shutdown(wait=True, **kwargs)
+    thread_pool.shutdown(wait=False, **kwargs)

+ 13 - 2
nicegui/static/nicegui.css

@@ -93,14 +93,25 @@
   padding: 0.5rem;
   padding: 0.5rem;
   border: 1px solid #8884;
   border: 1px solid #8884;
 }
 }
+h6.q-timeline__title {
+  font-size: 1.25rem;
+  font-weight: 500;
+}
+.nicegui-code {
+  position: relative;
+  background-color: rgba(127, 159, 191, 0.1);
+  border: 1pt solid rgba(127, 159, 191, 0.15);
+  box-shadow: 0 0 0.5em rgba(127, 159, 191, 0.05);
+  border-radius: 0.25rem;
+}
 
 
 #popup {
 #popup {
   position: fixed;
   position: fixed;
   bottom: 0;
   bottom: 0;
   left: 0;
   left: 0;
-  border: 1pt solid rgba(127, 127, 127, 0.25);
+  border: 1pt solid rgba(127, 159, 191, 0.25);
   border-radius: 0.25em;
   border-radius: 0.25em;
-  box-shadow: 0 0 0.5em rgba(127, 127, 127, 0.05);
+  box-shadow: 0 0 0.5em rgba(127, 159, 191, 0.05);
   margin: 2em;
   margin: 2em;
   padding: 1.5em 4em;
   padding: 1.5em 4em;
   display: flex;
   display: flex;

+ 1 - 1
nicegui/storage.py

@@ -42,7 +42,7 @@ class PersistentDict(observables.ObservableDict):
     def __init__(self, filepath: Path) -> None:
     def __init__(self, filepath: Path) -> None:
         self.filepath = filepath
         self.filepath = filepath
         data = json.loads(filepath.read_text()) if filepath.exists() else {}
         data = json.loads(filepath.read_text()) if filepath.exists() else {}
-        super().__init__(data, self.backup)
+        super().__init__(data, on_change=self.backup)
 
 
     def backup(self) -> None:
     def backup(self) -> None:
         if not self.filepath.exists():
         if not self.filepath.exists():

+ 8 - 0
nicegui/ui.py

@@ -13,6 +13,7 @@ __all__ = [
     'chart',
     'chart',
     'chat_message',
     'chat_message',
     'checkbox',
     'checkbox',
+    'code',
     'color_input',
     'color_input',
     'color_picker',
     'color_picker',
     'colors',
     'colors',
@@ -21,6 +22,7 @@ __all__ = [
     'date',
     'date',
     'dialog',
     'dialog',
     'echart',
     'echart',
+    'editor',
     'expansion',
     'expansion',
     'grid',
     'grid',
     'html',
     'html',
@@ -67,6 +69,8 @@ __all__ = [
     'tabs',
     'tabs',
     'textarea',
     'textarea',
     'time',
     'time',
+    'timeline',
+    'timeline_item',
     'toggle',
     'toggle',
     'tooltip',
     'tooltip',
     'tree',
     'tree',
@@ -106,6 +110,7 @@ from .elements.carousel import CarouselSlide as carousel_slide
 from .elements.chart import Chart as chart
 from .elements.chart import Chart as chart
 from .elements.chat_message import ChatMessage as chat_message
 from .elements.chat_message import ChatMessage as chat_message
 from .elements.checkbox import Checkbox as checkbox
 from .elements.checkbox import Checkbox as checkbox
+from .elements.code import Code as code
 from .elements.color_input import ColorInput as color_input
 from .elements.color_input import ColorInput as color_input
 from .elements.color_picker import ColorPicker as color_picker
 from .elements.color_picker import ColorPicker as color_picker
 from .elements.colors import Colors as colors
 from .elements.colors import Colors as colors
@@ -114,6 +119,7 @@ from .elements.dark_mode import DarkMode as dark_mode
 from .elements.date import Date as date
 from .elements.date import Date as date
 from .elements.dialog import Dialog as dialog
 from .elements.dialog import Dialog as dialog
 from .elements.echart import EChart as echart
 from .elements.echart import EChart as echart
+from .elements.editor import Editor as editor
 from .elements.expansion import Expansion as expansion
 from .elements.expansion import Expansion as expansion
 from .elements.grid import Grid as grid
 from .elements.grid import Grid as grid
 from .elements.html import Html as html
 from .elements.html import Html as html
@@ -160,6 +166,8 @@ from .elements.tabs import TabPanels as tab_panels
 from .elements.tabs import Tabs as tabs
 from .elements.tabs import Tabs as tabs
 from .elements.textarea import Textarea as textarea
 from .elements.textarea import Textarea as textarea
 from .elements.time import Time as time
 from .elements.time import Time as time
+from .elements.timeline import Timeline as timeline
+from .elements.timeline import TimelineEntry as timeline_entry
 from .elements.toggle import Toggle as toggle
 from .elements.toggle import Toggle as toggle
 from .elements.tooltip import Tooltip as tooltip
 from .elements.tooltip import Tooltip as tooltip
 from .elements.tree import Tree as tree
 from .elements.tree import Tree as tree

+ 8 - 8
nicegui/welcome.py

@@ -1,9 +1,9 @@
-import asyncio
 import os
 import os
 import socket
 import socket
 from typing import List
 from typing import List
 
 
 from . import globals  # pylint: disable=redefined-builtin
 from . import globals  # pylint: disable=redefined-builtin
+from .run_executor import io_bound
 
 
 try:
 try:
     import netifaces
     import netifaces
@@ -33,13 +33,13 @@ async def print_message() -> None:
     print('NiceGUI ready to go ', end='', flush=True)
     print('NiceGUI ready to go ', end='', flush=True)
     host = os.environ['NICEGUI_HOST']
     host = os.environ['NICEGUI_HOST']
     port = os.environ['NICEGUI_PORT']
     port = os.environ['NICEGUI_PORT']
-    loop = asyncio.get_running_loop()
-    ips = set((await loop.run_in_executor(None, get_all_ips)) if host == '0.0.0.0' else [])
+    ips = set((await io_bound(get_all_ips)) if host == '0.0.0.0' else [])
     ips.discard('127.0.0.1')
     ips.discard('127.0.0.1')
-    addresses = [(f'http://{ip}:{port}' if port != '80' else f'http://{ip}') for ip in ['localhost'] + sorted(ips)]
-    if len(addresses) >= 2:
-        addresses[-1] = 'and ' + addresses[-1]
+    urls = [(f'http://{ip}:{port}' if port != '80' else f'http://{ip}') for ip in ['localhost'] + sorted(ips)]
+    globals.app.urls.update(urls)
+    if len(urls) >= 2:
+        urls[-1] = 'and ' + urls[-1]
     extra = ''
     extra = ''
-    if 'netifaces' not in globals.optional_features:
+    if 'netifaces' not in globals.optional_features and os.environ.get('NO_NETIFACES', 'false').lower() != 'true':
         extra = ' (install netifaces to show all IPs and speedup this message)'
         extra = ' (install netifaces to show all IPs and speedup this message)'
-    print(f'on {", ".join(addresses)}' + extra, flush=True)
+    print(f'on {", ".join(urls)}' + extra, flush=True)

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 452 - 316
poetry.lock


+ 1 - 1
pyproject.toml

@@ -12,7 +12,7 @@ keywords = ["gui", "ui", "web", "interface", "live"]
 python = "^3.8"
 python = "^3.8"
 typing-extensions = ">=3.10.0"
 typing-extensions = ">=3.10.0"
 markdown2 = "^2.4.7"
 markdown2 = "^2.4.7"
-Pygments = ">=2.9.0,<3.0.0"
+Pygments = ">=2.15.1,<3.0.0"
 uvicorn = {extras = ["standard"], version = "^0.22.0"}
 uvicorn = {extras = ["standard"], version = "^0.22.0"}
 fastapi = ">=0.92,<1.0.0"
 fastapi = ">=0.92,<1.0.0"
 fastapi-socketio = "^0.0.10"
 fastapi-socketio = "^0.0.10"

+ 1 - 0
release.dockerfile

@@ -16,6 +16,7 @@ RUN chmod 777 /resources/docker-entrypoint.sh
 
 
 EXPOSE 8080
 EXPOSE 8080
 ENV PYTHONUNBUFFERED True
 ENV PYTHONUNBUFFERED True
+ENV NO_NETIFACES=true
 
 
 ENTRYPOINT ["/resources/docker-entrypoint.sh"]
 ENTRYPOINT ["/resources/docker-entrypoint.sh"]
 CMD ["python", "main.py"]
 CMD ["python", "main.py"]

+ 12 - 0
tests/test_code.py

@@ -0,0 +1,12 @@
+from nicegui import ui
+
+from .screen import Screen
+
+
+def test_code(screen: Screen):
+    ui.code('x = 42')
+
+    screen.open('/')
+    assert screen.find_by_class('n').text == 'x'
+    assert screen.find_by_class('o').text == '='
+    assert screen.find_by_class('mi').text == '42'

+ 63 - 0
tests/test_echart.py

@@ -0,0 +1,63 @@
+from nicegui import ui
+
+from .screen import Screen
+
+
+def test_create_dynamically(screen: Screen):
+    def create():
+        ui.echart({
+            'xAxis': {'type': 'value'},
+            'yAxis': {'type': 'category', 'data': ['A', 'B', 'C']},
+            'series': [{'type': 'line', 'data': [0.1, 0.2, 0.3]}],
+        })
+    ui.button('Create', on_click=create)
+
+    screen.open('/')
+    screen.click('Create')
+    assert screen.find_by_tag('canvas')
+
+
+def test_update(screen: Screen):
+    def update():
+        chart.options['xAxis'] = {'type': 'value'}
+        chart.options['yAxis'] = {'type': 'category', 'data': ['A', 'B', 'C']}
+        chart.options['series'] = [{'type': 'line', 'data': [0.1, 0.2, 0.3]}]
+        chart.update()
+    chart = ui.echart({})
+    ui.button('Update', on_click=update)
+
+    screen.open('/')
+    assert not screen.find_all_by_tag('canvas')
+    screen.click('Update')
+    assert screen.find_by_tag('canvas')
+
+
+def test_nested_card(screen: Screen):
+    with ui.card().style('height: 200px; width: 600px'):
+        ui.echart({
+            'xAxis': {'type': 'value'},
+            'yAxis': {'type': 'category', 'data': ['A', 'B', 'C']},
+            'series': [{'type': 'line', 'data': [0.1, 0.2, 0.3]}],
+        })
+
+    screen.open('/')
+    canvas = screen.find_by_tag('canvas')
+    assert canvas.rect['height'] == 168
+    assert canvas.rect['width'] == 568
+
+
+def test_nested_expansion(screen: Screen):
+    with ui.expansion() as expansion:
+        with ui.card().style('height: 200px; width: 600px'):
+            ui.echart({
+                'xAxis': {'type': 'value'},
+                'yAxis': {'type': 'category', 'data': ['A', 'B', 'C']},
+                'series': [{'type': 'line', 'data': [0.1, 0.2, 0.3]}],
+            })
+    ui.button('Open', on_click=expansion.open)
+
+    screen.open('/')
+    screen.click('Open')
+    canvas = screen.find_by_tag('canvas')
+    assert canvas.rect['height'] == 168
+    assert canvas.rect['width'] == 568

+ 14 - 0
tests/test_editor.py

@@ -0,0 +1,14 @@
+
+from nicegui import ui
+
+from .screen import Screen
+
+
+def test_editor(screen: Screen):
+    editor = ui.editor(placeholder='Type something here')
+    ui.markdown().bind_content_from(editor, 'value', backward=lambda v: f'HTML code:\n```\n{v}\n```')
+
+    screen.open('/')
+    screen.find_by_class('q-editor__content').click()
+    screen.type('Hello\nworld!')
+    screen.should_contain('Hello<div>world!</div>')

+ 18 - 0
tests/test_interactive_image.py

@@ -1,4 +1,5 @@
 import pytest
 import pytest
+from selenium.webdriver.common.action_chains import ActionChains
 
 
 from nicegui import Client, ui
 from nicegui import Client, ui
 
 
@@ -57,3 +58,20 @@ def test_replace_interactive_image(screen: Screen):
     screen.click('Replace')
     screen.click('Replace')
     screen.wait(0.5)
     screen.wait(0.5)
     assert screen.find_by_tag('img').get_attribute('src').endswith('id/30/640/360')
     assert screen.find_by_tag('img').get_attribute('src').endswith('id/30/640/360')
+
+
+@pytest.mark.parametrize('cross', [True, False])
+def test_mousemove_event(screen: Screen, cross: bool):
+    counter = {'value': 0}
+    ii = ui.interactive_image('https://picsum.photos/id/29/640/360', cross=cross, events=['mousemove'],
+                              on_mouse=lambda: counter.update(value=counter['value'] + 1))
+
+    screen.open('/')
+    element = screen.find_element(ii)
+    ActionChains(screen.selenium) \
+        .move_to_element_with_offset(element, 0, 0) \
+        .pause(0.5) \
+        .move_by_offset(10, 10) \
+        .pause(0.5) \
+        .perform()
+    assert counter['value'] > 0

+ 18 - 7
tests/test_observables.py

@@ -2,7 +2,7 @@ import asyncio
 import sys
 import sys
 
 
 from nicegui import ui
 from nicegui import ui
-from nicegui.observables import make_observable
+from nicegui.observables import ObservableDict, ObservableList, ObservableSet
 
 
 from .screen import Screen
 from .screen import Screen
 
 
@@ -28,7 +28,7 @@ async def increment_counter_slowly(_):
 
 
 def test_observable_dict():
 def test_observable_dict():
     reset_counter()
     reset_counter()
-    data = make_observable({}, increment_counter)
+    data = ObservableDict(on_change=increment_counter)
     data['a'] = 1
     data['a'] = 1
     assert count == 1
     assert count == 1
     del data['a']
     del data['a']
@@ -50,7 +50,7 @@ def test_observable_dict():
 
 
 def test_observable_list():
 def test_observable_list():
     reset_counter()
     reset_counter()
-    data = make_observable([], increment_counter)
+    data = ObservableList(on_change=increment_counter)
     data.append(1)
     data.append(1)
     assert count == 1
     assert count == 1
     data.extend([2, 3, 4])
     data.extend([2, 3, 4])
@@ -81,7 +81,7 @@ def test_observable_list():
 
 
 def test_observable_set():
 def test_observable_set():
     reset_counter()
     reset_counter()
-    data = make_observable({1, 2, 3, 4, 5}, increment_counter)
+    data = ObservableSet({1, 2, 3, 4, 5}, on_change=increment_counter)
     data.add(1)
     data.add(1)
     assert count == 1
     assert count == 1
     data.remove(1)
     data.remove(1)
@@ -112,12 +112,12 @@ def test_observable_set():
 
 
 def test_nested_observables():
 def test_nested_observables():
     reset_counter()
     reset_counter()
-    data = make_observable({
+    data = ObservableDict({
         'a': 1,
         'a': 1,
         'b': [1, 2, 3, {'x': 1, 'y': 2, 'z': 3}],
         'b': [1, 2, 3, {'x': 1, 'y': 2, 'z': 3}],
         'c': {'x': 1, 'y': 2, 'z': 3, 't': [1, 2, 3]},
         'c': {'x': 1, 'y': 2, 'z': 3, 't': [1, 2, 3]},
         'd': {1, 2, 3},
         'd': {1, 2, 3},
-    }, increment_counter)
+    }, on_change=increment_counter)
     data['a'] = 42
     data['a'] = 42
     assert count == 1
     assert count == 1
     data['b'].append(4)
     data['b'].append(4)
@@ -134,7 +134,7 @@ def test_nested_observables():
 
 
 def test_async_handler(screen: Screen):
 def test_async_handler(screen: Screen):
     reset_counter()
     reset_counter()
-    data = make_observable([], increment_counter_slowly)
+    data = ObservableList(on_change=increment_counter_slowly)
     ui.button('Append 42', on_click=lambda: data.append(42))
     ui.button('Append 42', on_click=lambda: data.append(42))
 
 
     screen.open('/')
     screen.open('/')
@@ -143,3 +143,14 @@ def test_async_handler(screen: Screen):
     screen.click('Append 42')
     screen.click('Append 42')
     screen.wait(0.5)
     screen.wait(0.5)
     assert count == 1
     assert count == 1
+
+
+def test_setting_change_handler():
+    reset_counter()
+    data = ObservableList()
+    data.append(1)
+    assert count == 0
+
+    data.on_change(increment_counter)
+    data.append(2)
+    assert count == 1

+ 14 - 1
tests/test_serving_files.py

@@ -3,8 +3,9 @@ from pathlib import Path
 
 
 import httpx
 import httpx
 import pytest
 import pytest
+import requests
 
 
-from nicegui import app, ui
+from nicegui import __version__, app, ui
 
 
 from .screen import Screen
 from .screen import Screen
 from .test_helpers import TEST_DIR
 from .test_helpers import TEST_DIR
@@ -81,3 +82,15 @@ def test_auto_serving_file_from_video_source(screen: Screen):
     video = screen.find_by_tag('video')
     video = screen.find_by_tag('video')
     assert '/_nicegui/auto/media/' in video.get_attribute('src')
     assert '/_nicegui/auto/media/' in video.get_attribute('src')
     assert_video_file_streaming(video.get_attribute('src'))
     assert_video_file_streaming(video.get_attribute('src'))
+
+
+def test_mimetypes_of_static_files(screen: Screen):
+    screen.open('/')
+
+    response = requests.get(f'http://localhost:{Screen.PORT}/_nicegui/{__version__}/static/vue.global.js', timeout=5)
+    assert response.status_code == 200
+    assert response.headers['Content-Type'].startswith('text/javascript')
+
+    response = requests.get(f'http://localhost:{Screen.PORT}/_nicegui/{__version__}/static/nicegui.css', timeout=5)
+    assert response.status_code == 200
+    assert response.headers['Content-Type'].startswith('text/css')

+ 11 - 1
tests/test_table.py

@@ -33,7 +33,7 @@ def test_table(screen: Screen):
     screen.should_contain('Lionel')
     screen.should_contain('Lionel')
 
 
 
 
-def test_pagination(screen: Screen):
+def test_pagination_int(screen: Screen):
     ui.table(columns=columns(), rows=rows(), pagination=2)
     ui.table(columns=columns(), rows=rows(), pagination=2)
 
 
     screen.open('/')
     screen.open('/')
@@ -43,6 +43,16 @@ def test_pagination(screen: Screen):
     screen.should_contain('1-2 of 3')
     screen.should_contain('1-2 of 3')
 
 
 
 
+def test_pagination_dict(screen: Screen):
+    ui.table(columns=columns(), rows=rows(), pagination={'rowsPerPage': 2})
+
+    screen.open('/')
+    screen.should_contain('Alice')
+    screen.should_contain('Bob')
+    screen.should_not_contain('Lionel')
+    screen.should_contain('1-2 of 3')
+
+
 def test_filter(screen: Screen):
 def test_filter(screen: Screen):
     table = ui.table(columns=columns(), rows=rows())
     table = ui.table(columns=columns(), rows=rows())
     ui.input('Search by name').bind_value(table, 'filter')
     ui.input('Search by name').bind_value(table, 'filter')

+ 16 - 0
tests/test_timeline.py

@@ -0,0 +1,16 @@
+from nicegui import ui
+
+from .screen import Screen
+
+
+def test_timeline(screen: Screen):
+    with ui.timeline():
+        ui.timeline_entry('Entry 1', title='Title 1', subtitle='Subtitle 1')
+        with ui.timeline():
+            ui.label('Entry 2')
+
+    screen.open('/')
+    screen.should_contain('Entry 1')
+    screen.should_contain('Title 1')
+    screen.should_contain('Subtitle 1')
+    screen.should_contain('Entry 2')

+ 12 - 0
tests/test_toggle.py

@@ -35,3 +35,15 @@ def test_changing_options(screen: Screen):
     screen.should_contain('value = 10')
     screen.should_contain('value = 10')
     screen.click('clear')
     screen.click('clear')
     screen.should_contain('value = None')
     screen.should_contain('value = None')
+
+
+def test_clearable_toggle(screen: Screen):
+    t = ui.toggle(['A', 'B', 'C'], clearable=True)
+    ui.label().bind_text_from(t, 'value', lambda v: f'value = {v}')
+
+    screen.open('/')
+    screen.click('A')
+    screen.should_contain('value = A')
+
+    screen.click('A')
+    screen.should_contain('value = None')

+ 16 - 6
website/demo.py

@@ -4,7 +4,7 @@ from typing import Callable, Literal, Optional, Union
 
 
 import isort
 import isort
 
 
-from nicegui import ui
+from nicegui import helpers, ui
 
 
 from .intersection_observer import IntersectionObserver as intersection_observer
 from .intersection_observer import IntersectionObserver as intersection_observer
 
 
@@ -53,8 +53,15 @@ def demo(f: Callable) -> Callable:
                 .on('click', copy_code, [])
                 .on('click', copy_code, [])
         with browser_window(title=getattr(f, 'tab', None),
         with browser_window(title=getattr(f, 'tab', None),
                             classes='w-full max-w-[44rem] min-[1500px]:max-w-[20rem] min-h-[10rem] browser-window') as window:
                             classes='w-full max-w-[44rem] min-[1500px]:max-w-[20rem] min-h-[10rem] browser-window') as window:
-            ui.spinner(size='lg').props('thickness=2')
-            intersection_observer(on_intersection=lambda: (window.clear(), f()))
+            spinner = ui.spinner(size='lg').props('thickness=2')
+
+            async def handle_intersection():
+                window.remove(spinner)
+                if helpers.is_coroutine_function(f):
+                    await f()
+                else:
+                    f()
+            intersection_observer(on_intersection=handle_intersection)
     return f
     return f
 
 
 
 
@@ -65,9 +72,9 @@ def _dots() -> None:
         ui.icon('circle').classes('text-[13px] text-green-400')
         ui.icon('circle').classes('text-[13px] text-green-400')
 
 
 
 
-def window(type: WindowType, *, title: str = '', tab: Union[str, Callable] = '', classes: str = '') -> ui.column:
+def window(type_: WindowType, *, title: str = '', tab: Union[str, Callable] = '', classes: str = '') -> ui.column:
     bar_color = ('#00000010', '#ffffff10')
     bar_color = ('#00000010', '#ffffff10')
-    color = WINDOW_BG_COLORS[type]
+    color = WINDOW_BG_COLORS[type_]
     with ui.card().classes(f'no-wrap bg-[{color[0]}] dark:bg-[{color[1]}] rounded-xl p-0 gap-0 {classes}') \
     with ui.card().classes(f'no-wrap bg-[{color[0]}] dark:bg-[{color[1]}] rounded-xl p-0 gap-0 {classes}') \
             .style('box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1)'):
             .style('box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1)'):
         with ui.row().classes(f'w-full h-8 p-2 bg-[{bar_color[0]}] dark:bg-[{bar_color[1]}]'):
         with ui.row().classes(f'w-full h-8 p-2 bg-[{bar_color[0]}] dark:bg-[{bar_color[1]}]'):
@@ -82,7 +89,10 @@ def window(type: WindowType, *, title: str = '', tab: Union[str, Callable] = '',
                         ui.label().classes(
                         ui.label().classes(
                             f'w-full h-full bg-[{bar_color[0]}] dark:bg-[{bar_color[1]}] rounded-br-[6px]')
                             f'w-full h-full bg-[{bar_color[0]}] dark:bg-[{bar_color[1]}] rounded-br-[6px]')
                     with ui.row().classes(f'text-sm text-gray-600 dark:text-gray-400 px-6 py-1 h-[24px] rounded-t-[6px] bg-[{color[0]}] dark:bg-[{color[1]}] items-center gap-2'):
                     with ui.row().classes(f'text-sm text-gray-600 dark:text-gray-400 px-6 py-1 h-[24px] rounded-t-[6px] bg-[{color[0]}] dark:bg-[{color[1]}] items-center gap-2'):
-                        tab() if callable(tab) else ui.label(tab)
+                        if callable(tab):
+                            tab()
+                        else:
+                            ui.label(tab)
                     with ui.label().classes(f'w-2 h-[24px] bg-[{color[0]}] dark:bg-[{color[1]}]'):
                     with ui.label().classes(f'w-2 h-[24px] bg-[{color[0]}] dark:bg-[{color[1]}]'):
                         ui.label().classes(
                         ui.label().classes(
                             f'w-full h-full bg-[{bar_color[0]}] dark:bg-[{bar_color[1]}] rounded-bl-[6px]')
                             f'w-full h-full bg-[{bar_color[0]}] dark:bg-[{bar_color[1]}] rounded-bl-[6px]')

+ 64 - 2
website/documentation.py

@@ -143,6 +143,8 @@ def create_full() -> None:
     load_demo(ui.scene)
     load_demo(ui.scene)
     load_demo(ui.tree)
     load_demo(ui.tree)
     load_demo(ui.log)
     load_demo(ui.log)
+    load_demo(ui.editor)
+    load_demo(ui.code)
     load_demo(ui.json_editor)
     load_demo(ui.json_editor)
 
 
     heading('Layout')
     heading('Layout')
@@ -182,6 +184,7 @@ def create_full() -> None:
     load_demo(ui.splitter)
     load_demo(ui.splitter)
     load_demo('tabs')
     load_demo('tabs')
     load_demo(ui.stepper)
     load_demo(ui.stepper)
+    load_demo(ui.timeline)
     load_demo(ui.carousel)
     load_demo(ui.carousel)
     load_demo(ui.menu)
     load_demo(ui.menu)
 
 
@@ -355,6 +358,49 @@ def create_full() -> None:
 
 
         ui.button('start async task', on_click=async_task)
         ui.button('start async task', on_click=async_task)
 
 
+    @text_demo('Running CPU-bound tasks', '''
+        NiceGUI provides a `cpu_bound` function for running CPU-bound tasks in a separate process.
+        This is useful for long-running computations that would otherwise block the event loop and make the UI unresponsive.
+        The function returns a future that can be awaited.
+    ''')
+    def cpu_bound_demo():
+        import time
+
+        from nicegui import run
+
+        def compute_sum(a: float, b: float) -> float:
+            time.sleep(1)  # simulate a long-running computation
+            return a + b
+
+        async def handle_click():
+            result = await run.cpu_bound(compute_sum, 1, 2)
+            ui.notify(f'Sum is {result}')
+
+        # ui.button('Compute', on_click=handle_click)
+        # END OF DEMO
+        async def mock_click():
+            import asyncio
+            await asyncio.sleep(1)
+            ui.notify('Sum is 3')
+        ui.button('Compute', on_click=mock_click)
+
+    @text_demo('Running I/O-bound tasks', '''
+        NiceGUI provides an `io_bound` function for running I/O-bound tasks in a separate thread.
+        This is useful for long-running I/O operations that would otherwise block the event loop and make the UI unresponsive.
+        The function returns a future that can be awaited.
+    ''')
+    def io_bound_demo():
+        import requests
+
+        from nicegui import run
+
+        async def handle_click():
+            URL = 'https://httpbin.org/delay/1'
+            response = await run.io_bound(requests.get, URL, timeout=3)
+            ui.notify(f'Downloaded {len(response.content)} bytes')
+
+        ui.button('Download', on_click=handle_click)
+
     heading('Pages')
     heading('Pages')
 
 
     load_demo(ui.page)
     load_demo(ui.page)
@@ -531,6 +577,21 @@ def create_full() -> None:
         ui.button('shutdown', on_click=lambda: ui.notify(
         ui.button('shutdown', on_click=lambda: ui.notify(
             'Nah. We do not actually shutdown the documentation server. Try it in your own app!'))
             'Nah. We do not actually shutdown the documentation server. Try it in your own app!'))
 
 
+    @text_demo('URLs', '''
+        You can access the list of all URLs on which the NiceGUI app is available via `app.urls`.
+        The URLs are not available in `app.on_startup` because the server is not yet running.
+        Instead, you can access them in a page function or register a callback with `app.urls.on_change`.
+    ''')
+    def urls_demo():
+        from nicegui import app
+
+        # @ui.page('/')
+        # def index():
+        #     for url in app.urls:
+        #         ui.link(url, target=url)
+        # END OF DEMO
+        ui.link('https://nicegui.io', target='https://nicegui.io')
+
     heading('NiceGUI Fundamentals')
     heading('NiceGUI Fundamentals')
 
 
     @text_demo('Auto-context', '''
     @text_demo('Auto-context', '''
@@ -599,6 +660,7 @@ def create_full() -> None:
             This will make `ui.pyplot` and `ui.line_plot` unavailable.
             This will make `ui.pyplot` and `ui.line_plot` unavailable.
         - `NICEGUI_STORAGE_PATH` (default: local ".nicegui") can be set to change the location of the storage files.
         - `NICEGUI_STORAGE_PATH` (default: local ".nicegui") can be set to change the location of the storage files.
         - `MARKDOWN_CONTENT_CACHE_SIZE` (default: 1000): The maximum number of Markdown content snippets that are cached in memory.
         - `MARKDOWN_CONTENT_CACHE_SIZE` (default: 1000): The maximum number of Markdown content snippets that are cached in memory.
+        - `NO_NETIFACES` (default: `false`): Can be set to `true` to hide the netifaces startup warning (e.g. in docker container).
     ''')
     ''')
     def env_var_demo():
     def env_var_demo():
         from nicegui.elements import markdown
         from nicegui.elements import markdown
@@ -681,7 +743,7 @@ def create_full() -> None:
 
 
                 ui.label('Hello from PyInstaller')
                 ui.label('Hello from PyInstaller')
 
 
-                ui.run(reload=False, native_mode.find_open_port())
+                ui.run(reload=False, port=native_mode.find_open_port())
                 ```
                 ```
             ''')
             ''')
         with demo.python_window('build.py', classes='max-w-lg w-full'):
         with demo.python_window('build.py', classes='max-w-lg w-full'):
@@ -764,7 +826,7 @@ def create_full() -> None:
         sys.stdout = open('logs.txt', 'w')
         sys.stdout = open('logs.txt', 'w')
         ```
         ```
         See <https://github.com/zauberzeug/nicegui/issues/681> for more information.
         See <https://github.com/zauberzeug/nicegui/issues/681> for more information.
-    ''')
+    ''').classes('bold-links arrow-links')
 
 
     subheading('NiceGUI On Air')
     subheading('NiceGUI On Air')
 
 

+ 1 - 1
website/more_documentation/aggrid_documentation.py

@@ -142,7 +142,7 @@ def more() -> None:
         All AG Grid events are passed through to NiceGUI via the AG Grid global listener.
         All AG Grid events are passed through to NiceGUI via the AG Grid global listener.
         These events can be subscribed to using the `.on()` method.
         These events can be subscribed to using the `.on()` method.
     ''')
     ''')
-    def aggrid_with_html_columns():
+    def aggrid_respond_to_event():
         ui.aggrid({
         ui.aggrid({
             'columnDefs': [
             'columnDefs': [
                 {'headerName': 'Name', 'field': 'name'},
                 {'headerName': 'Name', 'field': 'name'},

+ 2 - 2
website/more_documentation/button_documentation.py

@@ -4,14 +4,14 @@ from ..documentation_tools import text_demo
 
 
 
 
 def main_demo() -> None:
 def main_demo() -> None:
-    ui.button('Click me!', on_click=lambda: ui.notify(f'You clicked me!'))
+    ui.button('Click me!', on_click=lambda: ui.notify('You clicked me!'))
 
 
 
 
 def more() -> None:
 def more() -> None:
     @text_demo('Icons', '''
     @text_demo('Icons', '''
         You can also add an icon to a button.
         You can also add an icon to a button.
     ''')
     ''')
-    async def icons() -> None:
+    def icons() -> None:
         with ui.row():
         with ui.row():
             ui.button('demo', icon='history')
             ui.button('demo', icon='history')
             ui.button(icon='thumb_up')
             ui.button(icon='thumb_up')

+ 11 - 0
website/more_documentation/code_documentation.py

@@ -0,0 +1,11 @@
+from nicegui import ui
+
+
+def main_demo() -> None:
+    ui.code('''
+        from nicegui import ui
+        
+        ui.label('Code inception!')
+            
+        ui.run()
+    ''').classes('w-full')

+ 25 - 0
website/more_documentation/echart_documentation.py

@@ -1,5 +1,7 @@
 from nicegui import ui
 from nicegui import ui
 
 
+from ..documentation_tools import text_demo
+
 
 
 def main_demo() -> None:
 def main_demo() -> None:
     from random import random
     from random import random
@@ -19,3 +21,26 @@ def main_demo() -> None:
         echart.update()
         echart.update()
 
 
     ui.button('Update', on_click=update)
     ui.button('Update', on_click=update)
+
+
+def more() -> None:
+    @text_demo('EChart with clickable points', '''
+        You can register a callback for an event when a series point is clicked.
+    ''')
+    def clickable_points() -> None:
+        ui.echart({
+            'xAxis': {'type': 'category'},
+            'yAxis': {'type': 'value'},
+            'series': [{'type': 'line', 'data': [20, 10, 30, 50, 40, 30]}],
+        }, on_point_click=ui.notify)
+
+    @text_demo('EChart with dynamic properties', '''
+        Dynamic properties can be passed to chart elements to customize them such as apply an axis label format.
+        To make a property dynamic, prefix a colon ":" to the property name.
+    ''')
+    def dynamic_properties() -> None:
+        ui.echart({
+            'xAxis': {'type': 'category'},
+            'yAxis': {'axisLabel': {':formatter': 'value => "$" + value'}},
+            'series': [{'type': 'line', 'data': [5, 8, 13, 21, 34, 55]}],
+        })

+ 7 - 0
website/more_documentation/editor_documentation.py

@@ -0,0 +1,7 @@
+from nicegui import ui
+
+
+def main_demo() -> None:
+    editor = ui.editor(placeholder='Type something here')
+    ui.markdown().bind_content_from(editor, 'value',
+                                    backward=lambda v: f'HTML code:\n```\n{v}\n```')

+ 5 - 0
website/more_documentation/generic_events_documentation.py

@@ -18,6 +18,11 @@ def main_demo() -> None:
     The generic event handler can be synchronous or asynchronous and optionally takes `GenericEventArguments` as argument ("E").
     The generic event handler can be synchronous or asynchronous and optionally takes `GenericEventArguments` as argument ("E").
     You can also specify which attributes of the JavaScript or Quasar event should be passed to the handler ("F").
     You can also specify which attributes of the JavaScript or Quasar event should be passed to the handler ("F").
     This can reduce the amount of data that needs to be transferred between the server and the client.
     This can reduce the amount of data that needs to be transferred between the server and the client.
+
+    Here you can find more information about the events that are supported:
+
+    - https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement#events for HTML elements
+    - https://quasar.dev/vue-components for Quasar-based elements (see the "Events" tab on the individual component page)
     """
     """
     with ui.row():
     with ui.row():
         ui.button('A', on_click=lambda: ui.notify('You clicked the button A.'))
         ui.button('A', on_click=lambda: ui.notify('You clicked the button A.'))

+ 2 - 2
website/more_documentation/icon_documentation.py

@@ -14,7 +14,7 @@ def more() -> None:
     @text_demo('Eva icons', '''
     @text_demo('Eva icons', '''
         You can use [Eva icons](https://akveo.github.io/eva-icons/) in your app.
         You can use [Eva icons](https://akveo.github.io/eva-icons/) in your app.
     ''')
     ''')
-    async def eva_icons():
+    def eva_icons():
         # ui.add_head_html('<link href="https://unpkg.com/eva-icons@1.1.3/style/eva-icons.css" rel="stylesheet">')
         # ui.add_head_html('<link href="https://unpkg.com/eva-icons@1.1.3/style/eva-icons.css" rel="stylesheet">')
 
 
         ui.element('i').classes('eva eva-github').classes('text-5xl')
         ui.element('i').classes('eva eva-github').classes('text-5xl')
@@ -22,7 +22,7 @@ def more() -> None:
     @text_demo('Lottie files', '''
     @text_demo('Lottie files', '''
         You can also use [Lottie files](https://lottiefiles.com/) with animations.
         You can also use [Lottie files](https://lottiefiles.com/) with animations.
     ''')
     ''')
-    async def lottie():
+    def lottie():
         # ui.add_body_html('<script src="https://unpkg.com/@lottiefiles/lottie-player@latest/dist/lottie-player.js"></script>')
         # ui.add_body_html('<script src="https://unpkg.com/@lottiefiles/lottie-player@latest/dist/lottie-player.js"></script>')
 
 
         src = 'https://assets5.lottiefiles.com/packages/lf20_MKCnqtNQvg.json'
         src = 'https://assets5.lottiefiles.com/packages/lf20_MKCnqtNQvg.json'

+ 3 - 3
website/more_documentation/input_documentation.py

@@ -16,14 +16,14 @@ def more() -> None:
         The `autocomplete` feature provides suggestions as you type, making input easier and faster.
         The `autocomplete` feature provides suggestions as you type, making input easier and faster.
         The parameter `options` is a list of strings that contains the available options that will appear.
         The parameter `options` is a list of strings that contains the available options that will appear.
     ''')
     ''')
-    async def autocomplete_demo():
+    def autocomplete_demo():
         options = ['AutoComplete', 'NiceGUI', 'Awesome']
         options = ['AutoComplete', 'NiceGUI', 'Awesome']
         ui.input(label='Text', placeholder='start typing', autocomplete=options)
         ui.input(label='Text', placeholder='start typing', autocomplete=options)
 
 
     @text_demo('Clearable', '''
     @text_demo('Clearable', '''
         The `clearable` prop from [Quasar](https://quasar.dev/) adds a button to the input that clears the text.    
         The `clearable` prop from [Quasar](https://quasar.dev/) adds a button to the input that clears the text.    
     ''')
     ''')
-    async def clearable():
+    def clearable():
         i = ui.input(value='some text').props('clearable')
         i = ui.input(value='some text').props('clearable')
         ui.label().bind_text_from(i, 'value')
         ui.label().bind_text_from(i, 'value')
 
 
@@ -32,7 +32,7 @@ def more() -> None:
         It is even possible to style the underlying input with `input-style` and `input-class` props
         It is even possible to style the underlying input with `input-style` and `input-class` props
         and use the provided slots to add custom elements.
         and use the provided slots to add custom elements.
     ''')
     ''')
-    async def styling():
+    def styling():
         ui.input(placeholder='start typing').props('rounded outlined dense')
         ui.input(placeholder='start typing').props('rounded outlined dense')
         ui.input('styling', value='some text') \
         ui.input('styling', value='some text') \
             .props('input-style="color: blue" input-class="font-mono"')
             .props('input-style="color: blue" input-class="font-mono"')

+ 3 - 1
website/more_documentation/menu_documentation.py

@@ -18,7 +18,9 @@ def main_demo() -> None:
 
 
 def more() -> None:
 def more() -> None:
     @text_demo('Custom Context Menu', '''
     @text_demo('Custom Context Menu', '''
-        Using Quasar's `context-menu` and `touch-position` props, you can create custom context menus.
+        Using [Quasar's `context-menu`](https://quasar.dev/vue-components/menu#context-menu) and `touch-position` props, 
+        you can create custom context menus. 
+        These open by right-clicking on the parent.
     ''')
     ''')
     def custom_context_menu() -> None:
     def custom_context_menu() -> None:
         with ui.image('https://picsum.photos/id/377/640/360'):
         with ui.image('https://picsum.photos/id/377/640/360'):

+ 1 - 1
website/more_documentation/number_documentation.py

@@ -14,6 +14,6 @@ def more() -> None:
     @text_demo('Clearable', '''
     @text_demo('Clearable', '''
         The `clearable` prop from [Quasar](https://quasar.dev/) adds a button to the input that clears the text.    
         The `clearable` prop from [Quasar](https://quasar.dev/) adds a button to the input that clears the text.    
     ''')
     ''')
-    async def clearable():
+    def clearable():
         i = ui.number(value=42).props('clearable')
         i = ui.number(value=42).props('clearable')
         ui.label().bind_text_from(i, 'value')
         ui.label().bind_text_from(i, 'value')

+ 2 - 0
website/more_documentation/storage_documentation.py

@@ -15,11 +15,13 @@ def main_demo() -> None:
     - `app.storage.user`:
     - `app.storage.user`:
         Stored server-side, each dictionary is associated with a unique identifier held in a browser session cookie.
         Stored server-side, each dictionary is associated with a unique identifier held in a browser session cookie.
         Unique to each user, this storage is accessible across all their browser tabs.
         Unique to each user, this storage is accessible across all their browser tabs.
+        `app.storage.browser['id']` is used to identify the user.
     - `app.storage.general`:
     - `app.storage.general`:
         Also stored server-side, this dictionary provides a shared storage space accessible to all users.
         Also stored server-side, this dictionary provides a shared storage space accessible to all users.
     - `app.storage.browser`:
     - `app.storage.browser`:
         Unlike the previous types, this dictionary is stored directly as the browser session cookie, shared among all browser tabs for the same user.
         Unlike the previous types, this dictionary is stored directly as the browser session cookie, shared among all browser tabs for the same user.
         However, `app.storage.user` is generally preferred due to its advantages in reducing data payload, enhancing security, and offering larger storage capacity.
         However, `app.storage.user` is generally preferred due to its advantages in reducing data payload, enhancing security, and offering larger storage capacity.
+        By default, NiceGUI holds a unique identifier for the browser session in `app.storage.browser['id']`.
 
 
     The user storage and browser storage are only available within `page builder functions </documentation/page>`_
     The user storage and browser storage are only available within `page builder functions </documentation/page>`_
     because they are accessing the underlying `Request` object from FastAPI.
     because they are accessing the underlying `Request` object from FastAPI.

+ 26 - 0
website/more_documentation/table_documentation.py

@@ -201,3 +201,29 @@ def more() -> None:
                 table.toggle_fullscreen()
                 table.toggle_fullscreen()
                 button.props('icon=fullscreen_exit' if table.is_fullscreen else 'icon=fullscreen')
                 button.props('icon=fullscreen_exit' if table.is_fullscreen else 'icon=fullscreen')
             button = ui.button('Toggle fullscreen', icon='fullscreen', on_click=toggle).props('flat')
             button = ui.button('Toggle fullscreen', icon='fullscreen', on_click=toggle).props('flat')
+
+    @text_demo('Pagination', '''
+        You can provide either a single integer or a dictionary to define pagination.
+
+        The dictionary can contain the following keys:
+
+        - `rowsPerPage`: The number of rows per page.
+        - `sortBy`: The column name to sort by.
+        - `descending`: Whether to sort in descending order.
+        - `page`: The current page (1-based).
+    ''')
+    def pagination() -> None:
+        columns = [
+            {'name': 'name', 'label': 'Name', 'field': 'name', 'required': True, 'align': 'left'},
+            {'name': 'age', 'label': 'Age', 'field': 'age', 'sortable': True},
+        ]
+        rows = [
+            {'name': 'Elsa', 'age': 18},
+            {'name': 'Oaken', 'age': 46},
+            {'name': 'Hans', 'age': 20},
+            {'name': 'Sven'},
+            {'name': 'Olaf', 'age': 4},
+            {'name': 'Anna', 'age': 17},
+        ]
+        ui.table(columns=columns, rows=rows, pagination=3)
+        ui.table(columns=columns, rows=rows, pagination={'rowsPerPage': 4, 'sortBy': 'age', 'page': 2})

+ 1 - 1
website/more_documentation/textarea_documentation.py

@@ -14,6 +14,6 @@ def more() -> None:
     @text_demo('Clearable', '''
     @text_demo('Clearable', '''
         The `clearable` prop from [Quasar](https://quasar.dev/) adds a button to the input that clears the text.    
         The `clearable` prop from [Quasar](https://quasar.dev/) adds a button to the input that clears the text.    
     ''')
     ''')
-    async def clearable():
+    def clearable():
         i = ui.textarea(value='some text').props('clearable')
         i = ui.textarea(value='some text').props('clearable')
         ui.label().bind_text_from(i, 'value')
         ui.label().bind_text_from(i, 'value')

+ 16 - 0
website/more_documentation/timeline_documentation.py

@@ -0,0 +1,16 @@
+from nicegui import ui
+
+
+def main_demo() -> None:
+    with ui.timeline(side='right'):
+        ui.timeline_entry('Rodja and Falko start working on NiceGUI.',
+                          title='Initial commit',
+                          subtitle='May 07, 2021')
+        ui.timeline_entry('The first PyPI package is released.',
+                          title='Release of 0.1',
+                          subtitle='May 14, 2021')
+        ui.timeline_entry('Large parts are rewritten to remove JustPy '
+                          'and to upgrade to Vue 3 and Quasar 2.',
+                          title='Release of 1.0',
+                          subtitle='December 15, 2022',
+                          icon='rocket')

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 11 - 1
website/static/search_index.json


Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů