Browse Source

Merge branch 'main' into pr/jacoverster/1532

Rodja Trappe 1 year ago
parent
commit
12d5d79b02
58 changed files with 1345 additions and 528 deletions
  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
       - name: setup chromedriver
         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
         run: pytest
       - name: upload screenshots

+ 3 - 11
examples/ai_interface/main.py

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

+ 32 - 5
examples/authentication/main.py

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

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

+ 3 - 12
examples/progress/main.py

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

+ 2 - 0
examples/slideshow/main.py

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

+ 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')
             example_link('Download Text as File', 'providing in-memory data like strings as file download')
             example_link('Generate PDF', 'create SVG preview and PDF download from input form elements')
+            example_link('Custom Binding', 'create a custom binding for a label with a bindable background color')
 
     with ui.row().classes('dark-box min-h-screen mt-16'):
         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 .client import Client
 from .nicegui import app
@@ -11,6 +13,7 @@ __all__ = [
     'Client',
     'elements',
     'globals',
+    'run',
     'Tailwind',
     'ui',
     '__version__',

+ 3 - 4
nicegui/air.py

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

+ 2 - 0
nicegui/app.py

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

+ 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 {
   template: "<div></div>",
   mounted() {
     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() {
-    this.destroyChart();
+    this.chart.dispose();
   },
   beforeUnmount() {
-    this.destroyChart();
+    this.chart.dispose();
   },
   methods: {
     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: {

+ 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 ..events import EChartPointClickEventArguments, GenericEventArguments, handle_event
 
 
 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
 
         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.
 
         :param options: dictionary of EChart options
+        :param on_click_point: callback function that is called when a point is clicked
         """
         super().__init__()
         self._props['options'] = options
         self._classes = ['nicegui-echart']
 
+        if on_point_click:
+            def handle_point_click(e: GenericEventArguments) -> None:
+                handle_event(on_point_click, EChartPointClickEventArguments(
+                    sender=self,
+                    client=self.client,
+                    component_type=e.args['componentType'],
+                    series_type=e.args['seriesType'],
+                    series_index=e.args['seriesIndex'],
+                    series_name=e.args['seriesName'],
+                    name=e.args['name'],
+                    data_index=e.args['dataIndex'],
+                    data=e.args['data'],
+                    data_type=e.args.get('dataType'),
+                    value=e.args['value'],
+                ))
+            self.on('pointClick', handle_point_click, [
+                'componentType',
+                'seriesType',
+                'seriesIndex',
+                'seriesName',
+                'name',
+                'dataIndex',
+                'data',
+                'dataType',
+                'value',
+            ])
+
     @property
     def options(self) -> Dict:
         return self._props['options']

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

+ 7 - 0
nicegui/elements/stepper.py

@@ -13,16 +13,23 @@ class Stepper(ValueElement):
     def __init__(self, *,
                  value: Union[str, Step, None] = None,
                  on_value_change: Optional[Callable[..., Any]] = None,
+                 keep_alive: bool = True,
                  ) -> None:
         """Stepper
 
         This element represents `Quasar's QStepper <https://quasar.dev/vue-components/stepper#qstepper-api>`_ component.
         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 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)
+        self._props['keep-alive'] = keep_alive
 
     def _value_to_model_value(self, value: Any) -> Any:
         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 ..events import GenericEventArguments, TableSelectionEventArguments, handle_event
@@ -13,7 +13,7 @@ class Table(FilterElement, component='table.js'):
                  row_key: str = 'id',
                  title: Optional[str] = None,
                  selection: Optional[Literal['single', 'multiple']] = None,
-                 pagination: Optional[int] = None,
+                 pagination: Optional[Union[int, dict]] = None,
                  on_select: Optional[Callable[..., Any]] = None,
                  ) -> None:
         """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 title: title of the table
         :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
 
         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['title'] = title
         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['selected'] = self.selected
         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], *,
                  value: Any = None,
                  on_change: Optional[Callable[..., Any]] = None,
+                 clearable: bool = False,
                  ) -> None:
         """Toggle
 
@@ -20,11 +21,13 @@ class Toggle(ChoiceElement, DisableableElement):
         :param options: a list ['value1', ...] or dictionary `{'value1':'label1', ...}` specifying the options
         :param value: the initial value
         :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)
+        self._props['clearable'] = clearable
 
     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:
         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:
     from .client import Client
     from .element import Element
-    from .observables import ObservableDict, ObservableList, ObservableSet
+    from .observables import ObservableCollection
 
 
 @dataclass(**KWONLY_SLOTS)
@@ -22,7 +22,7 @@ class EventArguments:
 
 @dataclass(**KWONLY_SLOTS)
 class ObservableChangeEventArguments(EventArguments):
-    sender: Union[ObservableDict, ObservableList, ObservableSet]
+    sender: ObservableCollection
 
 
 @dataclass(**KWONLY_SLOTS)
@@ -53,6 +53,19 @@ class ChartEventArguments(UiEventArguments):
     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)
 class ChartPointClickEventArguments(ChartEventArguments):
     series_index: int

+ 2 - 2
nicegui/native.py

@@ -1,4 +1,3 @@
-import asyncio
 import inspect
 import warnings
 from dataclasses import dataclass, field
@@ -8,6 +7,7 @@ from typing import Any, Callable, Dict, Optional, Tuple
 
 from .dataclasses import KWONLY_SLOTS
 from .globals import log
+from .run_executor import io_bound
 
 method_queue: Queue = Queue()
 response_queue: Queue = Queue()
@@ -123,7 +123,7 @@ try:
                     log.exception(f'error in {name}')
                     return None
             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:
             self._send()

+ 7 - 1
nicegui/nicegui.py

@@ -1,4 +1,5 @@
 import asyncio
+import mimetypes
 import time
 import urllib.parse
 from pathlib import Path
@@ -10,7 +11,8 @@ from fastapi.responses import FileResponse, Response
 from fastapi.staticfiles import StaticFiles
 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 .client import Client
 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)
 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(RedirectWithPrefixMiddleware)
 static_files = StaticFiles(
@@ -105,6 +110,7 @@ async def handle_shutdown() -> None:
     with globals.index_client:
         for t in globals.shutdown_handlers:
             safe_invoke(t)
+    run_executor.tear_down()
     globals.state = globals.State.STOPPED
     if globals.air:
         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():
-            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:
         item = super().pop(k, d)
-        self.on_change()
+        self._handle_change()
         return item
 
     def popitem(self) -> Any:
         item = super().popitem()
-        self.on_change()
+        self._handle_change()
         return item
 
     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:
         super().clear()
-        self.on_change()
+        self._handle_change()
 
     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
 
     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:
         super().__delitem__(__key)
-        self.on_change()
+        self._handle_change()
 
     def __or__(self, other: Any) -> Any:
         return super().__or__(other)
 
     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
 
 
-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):
-            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:
-        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:
-        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:
-        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:
         super().remove(value)
-        self.on_change()
+        self._handle_change()
 
     def pop(self, index: SupportsIndex = -1) -> Any:
         item = super().pop(index)
-        self.on_change()
+        self._handle_change()
         return item
 
     def clear(self) -> None:
         super().clear()
-        self.on_change()
+        self._handle_change()
 
     def sort(self, **kwargs: Any) -> None:
         super().sort(**kwargs)
-        self.on_change()
+        self._handle_change()
 
     def reverse(self) -> None:
         super().reverse()
-        self.on_change()
+        self._handle_change()
 
     def __delitem__(self, key: Union[SupportsIndex, slice]) -> None:
         super().__delitem__(key)
-        self.on_change()
+        self._handle_change()
 
     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:
         return super().__add__(other)
 
     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
 
 
-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:
-            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:
-        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:
         super().remove(item)
-        self.on_change()
+        self._handle_change()
 
     def discard(self, item: Any) -> None:
         super().discard(item)
-        self.on_change()
+        self._handle_change()
 
     def pop(self) -> Any:
         item = super().pop()
-        self.on_change()
+        self._handle_change()
         return item
 
     def clear(self) -> None:
         super().clear()
-        self.on_change()
+        self._handle_change()
 
     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:
         super().intersection_update(*s)
-        self.on_change()
+        self._handle_change()
 
     def difference_update(self, *s: Iterable[Any]) -> None:
         super().difference_update(*s)
-        self.on_change()
+        self._handle_change()
 
     def symmetric_difference_update(self, *s: Iterable[Any]) -> None:
         super().symmetric_difference_update(*s)
-        self.on_change()
+        self._handle_change()
 
     def __or__(self, other: Any) -> Any:
         return super().__or__(other)
 
     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
 
     def __and__(self, other: Any) -> set:
         return super().__and__(other)
 
     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
 
     def __sub__(self, other: Any) -> set:
         return super().__sub__(other)
 
     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
 
     def __xor__(self, other: Any) -> set:
         return super().__xor__(other)
 
     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
-
-
-@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;
   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 {
   position: fixed;
   bottom: 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;
-  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;
   padding: 1.5em 4em;
   display: flex;

+ 1 - 1
nicegui/storage.py

@@ -42,7 +42,7 @@ class PersistentDict(observables.ObservableDict):
     def __init__(self, filepath: Path) -> None:
         self.filepath = filepath
         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:
         if not self.filepath.exists():

+ 8 - 0
nicegui/ui.py

@@ -13,6 +13,7 @@ __all__ = [
     'chart',
     'chat_message',
     'checkbox',
+    'code',
     'color_input',
     'color_picker',
     'colors',
@@ -21,6 +22,7 @@ __all__ = [
     'date',
     'dialog',
     'echart',
+    'editor',
     'expansion',
     'grid',
     'html',
@@ -67,6 +69,8 @@ __all__ = [
     'tabs',
     'textarea',
     'time',
+    'timeline',
+    'timeline_item',
     'toggle',
     'tooltip',
     'tree',
@@ -106,6 +110,7 @@ from .elements.carousel import CarouselSlide as carousel_slide
 from .elements.chart import Chart as chart
 from .elements.chat_message import ChatMessage as chat_message
 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_picker import ColorPicker as color_picker
 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.dialog import Dialog as dialog
 from .elements.echart import EChart as echart
+from .elements.editor import Editor as editor
 from .elements.expansion import Expansion as expansion
 from .elements.grid import Grid as grid
 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.textarea import Textarea as textarea
 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.tooltip import Tooltip as tooltip
 from .elements.tree import Tree as tree

+ 8 - 8
nicegui/welcome.py

@@ -1,9 +1,9 @@
-import asyncio
 import os
 import socket
 from typing import List
 
 from . import globals  # pylint: disable=redefined-builtin
+from .run_executor import io_bound
 
 try:
     import netifaces
@@ -33,13 +33,13 @@ async def print_message() -> None:
     print('NiceGUI ready to go ', end='', flush=True)
     host = os.environ['NICEGUI_HOST']
     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')
-    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 = ''
-    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)'
-    print(f'on {", ".join(addresses)}' + extra, flush=True)
+    print(f'on {", ".join(urls)}' + extra, flush=True)

File diff suppressed because it is too large
+ 452 - 316
poetry.lock


+ 1 - 1
pyproject.toml

@@ -12,7 +12,7 @@ keywords = ["gui", "ui", "web", "interface", "live"]
 python = "^3.8"
 typing-extensions = ">=3.10.0"
 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"}
 fastapi = ">=0.92,<1.0.0"
 fastapi-socketio = "^0.0.10"

+ 1 - 0
release.dockerfile

@@ -16,6 +16,7 @@ RUN chmod 777 /resources/docker-entrypoint.sh
 
 EXPOSE 8080
 ENV PYTHONUNBUFFERED True
+ENV NO_NETIFACES=true
 
 ENTRYPOINT ["/resources/docker-entrypoint.sh"]
 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
+from selenium.webdriver.common.action_chains import ActionChains
 
 from nicegui import Client, ui
 
@@ -57,3 +58,20 @@ def test_replace_interactive_image(screen: Screen):
     screen.click('Replace')
     screen.wait(0.5)
     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
 
 from nicegui import ui
-from nicegui.observables import make_observable
+from nicegui.observables import ObservableDict, ObservableList, ObservableSet
 
 from .screen import Screen
 
@@ -28,7 +28,7 @@ async def increment_counter_slowly(_):
 
 def test_observable_dict():
     reset_counter()
-    data = make_observable({}, increment_counter)
+    data = ObservableDict(on_change=increment_counter)
     data['a'] = 1
     assert count == 1
     del data['a']
@@ -50,7 +50,7 @@ def test_observable_dict():
 
 def test_observable_list():
     reset_counter()
-    data = make_observable([], increment_counter)
+    data = ObservableList(on_change=increment_counter)
     data.append(1)
     assert count == 1
     data.extend([2, 3, 4])
@@ -81,7 +81,7 @@ def test_observable_list():
 
 def test_observable_set():
     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)
     assert count == 1
     data.remove(1)
@@ -112,12 +112,12 @@ def test_observable_set():
 
 def test_nested_observables():
     reset_counter()
-    data = make_observable({
+    data = ObservableDict({
         'a': 1,
         'b': [1, 2, 3, {'x': 1, 'y': 2, 'z': 3}],
         'c': {'x': 1, 'y': 2, 'z': 3, 't': [1, 2, 3]},
         'd': {1, 2, 3},
-    }, increment_counter)
+    }, on_change=increment_counter)
     data['a'] = 42
     assert count == 1
     data['b'].append(4)
@@ -134,7 +134,7 @@ def test_nested_observables():
 
 def test_async_handler(screen: Screen):
     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))
 
     screen.open('/')
@@ -143,3 +143,14 @@ def test_async_handler(screen: Screen):
     screen.click('Append 42')
     screen.wait(0.5)
     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 pytest
+import requests
 
-from nicegui import app, ui
+from nicegui import __version__, app, ui
 
 from .screen import Screen
 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')
     assert '/_nicegui/auto/media/' in 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')
 
 
-def test_pagination(screen: Screen):
+def test_pagination_int(screen: Screen):
     ui.table(columns=columns(), rows=rows(), pagination=2)
 
     screen.open('/')
@@ -43,6 +43,16 @@ def test_pagination(screen: Screen):
     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):
     table = ui.table(columns=columns(), rows=rows())
     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.click('clear')
     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
 
-from nicegui import ui
+from nicegui import helpers, ui
 
 from .intersection_observer import IntersectionObserver as intersection_observer
 
@@ -53,8 +53,15 @@ def demo(f: Callable) -> Callable:
                 .on('click', copy_code, [])
         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:
-            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
 
 
@@ -65,9 +72,9 @@ def _dots() -> None:
         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')
-    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}') \
             .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]}]'):
@@ -82,7 +89,10 @@ def window(type: WindowType, *, title: str = '', tab: Union[str, Callable] = '',
                         ui.label().classes(
                             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'):
-                        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]}]'):
                         ui.label().classes(
                             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.tree)
     load_demo(ui.log)
+    load_demo(ui.editor)
+    load_demo(ui.code)
     load_demo(ui.json_editor)
 
     heading('Layout')
@@ -182,6 +184,7 @@ def create_full() -> None:
     load_demo(ui.splitter)
     load_demo('tabs')
     load_demo(ui.stepper)
+    load_demo(ui.timeline)
     load_demo(ui.carousel)
     load_demo(ui.menu)
 
@@ -355,6 +358,49 @@ def create_full() -> None:
 
         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')
 
     load_demo(ui.page)
@@ -531,6 +577,21 @@ def create_full() -> None:
         ui.button('shutdown', on_click=lambda: ui.notify(
             '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')
 
     @text_demo('Auto-context', '''
@@ -599,6 +660,7 @@ def create_full() -> None:
             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.
         - `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():
         from nicegui.elements import markdown
@@ -681,7 +743,7 @@ def create_full() -> None:
 
                 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'):
@@ -764,7 +826,7 @@ def create_full() -> None:
         sys.stdout = open('logs.txt', 'w')
         ```
         See <https://github.com/zauberzeug/nicegui/issues/681> for more information.
-    ''')
+    ''').classes('bold-links arrow-links')
 
     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.
         These events can be subscribed to using the `.on()` method.
     ''')
-    def aggrid_with_html_columns():
+    def aggrid_respond_to_event():
         ui.aggrid({
             'columnDefs': [
                 {'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:
-    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:
     @text_demo('Icons', '''
         You can also add an icon to a button.
     ''')
-    async def icons() -> None:
+    def icons() -> None:
         with ui.row():
             ui.button('demo', icon='history')
             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 ..documentation_tools import text_demo
+
 
 def main_demo() -> None:
     from random import random
@@ -19,3 +21,26 @@ def main_demo() -> None:
         echart.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").
     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.
+
+    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():
         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', '''
         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.element('i').classes('eva eva-github').classes('text-5xl')
@@ -22,7 +22,7 @@ def more() -> None:
     @text_demo('Lottie files', '''
         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>')
 
         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 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']
         ui.input(label='Text', placeholder='start typing', autocomplete=options)
 
     @text_demo('Clearable', '''
         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')
         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
         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('styling', value='some text') \
             .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:
     @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:
         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', '''
         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')
         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`:
         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.
+        `app.storage.browser['id']` is used to identify the user.
     - `app.storage.general`:
         Also stored server-side, this dictionary provides a shared storage space accessible to all users.
     - `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.
         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>`_
     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()
                 button.props('icon=fullscreen_exit' if table.is_fullscreen else 'icon=fullscreen')
             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', '''
         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')
         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')

File diff suppressed because it is too large
+ 11 - 1
website/static/search_index.json


Some files were not shown because too many files changed in this diff