Преглед изворни кода

Merge branch 'main' into javascript

Falko Schindler пре 2 година
родитељ
комит
dbed1a0271

+ 25 - 44
README.md

@@ -68,43 +68,40 @@ python3 main.py
 ```
 ```
 
 
 The GUI is now available through http://localhost:8080/ in your browser.
 The GUI is now available through http://localhost:8080/ in your browser.
-Note: The script will automatically reload the page when you modify the code.
+Note: NiceGUI will automatically reload the page when you modify the code.
 
 
-Full documentation can be found at [https://nicegui.io](https://nicegui.io).
+## Documentation and Examples
 
 
-## Configuration
+The API reference is hosted at [https://nicegui.io/reference](https://nicegui.io/reference) and provides a ton of live examples.
+The whole content of [https://nicegui.io](https://nicegui.io) is [implemented with NiceGUI itself](https://github.com/zauberzeug/nicegui/blob/main/main.py).
 
 
-You can call `ui.run()` with optional arguments:
+You may also have a look at the following examples for in-depth demonstrations of what you can do with NiceGUI:
 
 
-- `host` (default: `'0.0.0.0'`)
-- `port` (default: `8080`)
-- `title` (default: `'NiceGUI'`)
-- `favicon` (default: `'favicon.ico'`)
-- `dark`: whether to use Quasar's dark mode (default: `False`, use `None` for "auto" mode)
-- `main_page_classes`: configure Quasar classes of main page (default: `'q-ma-md column items-start'`)
-- `binding_refresh_interval`: time between binding updates (default: `0.1` seconds, bigger is more cpu friendly)
-- `show`: automatically open the ui in a browser tab (default: `True`)
-- `reload`: automatically reload the ui on file changes (default: `True`)
-- `uvicorn_logging_level`: logging level for uvicorn server (default: `'warning'`)
-- `uvicorn_reload_dirs`: string with comma-separated list for directories to be monitored (default is current working directory only)
-- `uvicorn_reload_includes`: string with comma-separated list of glob-patterns which trigger reload on modification (default: `'.py'`)
-- `uvicorn_reload_excludes`: string with comma-separated list of glob-patterns which should be ignored for reload (default: `'.*, .py[cod], .sw.*, ~*'`)
-- `exclude`: comma-separated string to exclude elements (with corresponding JavaScript libraries) to save bandwidth
-  (possible entries: chart, colors, custom_example, interactive_image, keyboard, log, joystick, scene, table)
+- [Slideshow](https://github.com/zauberzeug/nicegui/tree/main/examples/slideshow/main.py):
+  implements a keyboard-controlled image slideshow
+- [Authentication](https://github.com/zauberzeug/nicegui/blob/main/examples/authentication/main.py):
+  shows how to use sessions to build a login screen
+- [Customization](https://github.com/zauberzeug/nicegui/blob/main/examples/customization/main.py):
+  provides an example of how to modularize your application into multiple files and create a specialized `@ui.page` decorator
+- [Map](https://github.com/zauberzeug/nicegui/blob/main/examples/map/main.py):
+  uses the JavaScript library [leaflet](https://leafletjs.com/) to display a map at specific locations
+- [AI User Interface](https://github.com/zauberzeug/nicegui/blob/main/examples/ai_interface/main.py):
+  utilizes the great but non-async API from <https://replicate.com> to perform voice-to-text transcription and generate images from prompts with Stable Diffusion
+- [3D scene](https://github.com/zauberzeug/nicegui/blob/main/examples/3d_scene/main.py):
+  creates a 3D scene and loads an STL mesh illuminated with a spotlight
 
 
-The environment variables `HOST` and `PORT` can also be used to configure NiceGUI.
+## Why?
 
 
-To avoid the potentially costly import of Matplotlib, you set the environment variable `MATPLOTLIB=false`.
-This will make `ui.plot` and `ui.line_plot` unavailable.
+We like [Streamlit](https://streamlit.io/) but find it does [too much magic when it comes to state handling](https://github.com/zauberzeug/nicegui/issues/1#issuecomment-847413651).
+In search for an alternative nice library to write simple graphical user interfaces in Python we discovered [JustPy](https://justpy.io/).
+While we liked the approach, it is too "low-level HTML" for our daily usage.
 
 
-Note:
-The parameter `exclude` from earlier versions of NiceGUI has been removed.
-Libraries are now automatically served on demand.
-As a small caveat, the page will be reloaded if a new dependency is added dynamically, e.g. when adding a `ui.chart` only after pressing a button.
+Therefore we created NiceGUI on top of [JustPy](https://justpy.io/),
+which itself is based on the ASGI framework [Starlette](https://www.starlette.io/) (like [FastAPI](https://fastapi.tiangolo.com/)) and the ASGI webserver [Uvicorn](https://www.uvicorn.org/).
 
 
 ## Docker
 ## Docker
 
 
-You can use our [multi-arch Docker image](https://hub.docker.com/repository/docker/zauberzeug/nicegui) for pain-free installation:
+You can use our [multi-arch Docker image](https://hub.docker.com/repository/docker/zauberzeug/nicegui):
 
 
 ```bash
 ```bash
 docker run --rm -p 8888:8080 -v $(pwd):/app/ -it zauberzeug/nicegui:latest
 docker run --rm -p 8888:8080 -v $(pwd):/app/ -it zauberzeug/nicegui:latest
@@ -114,26 +111,10 @@ This will start the server at http://localhost:8888 with the code from your curr
 The file containing your `ui.run(port=8080, ...)` command must be named `main.py`.
 The file containing your `ui.run(port=8080, ...)` command must be named `main.py`.
 Code modification triggers an automatic reload.
 Code modification triggers an automatic reload.
 
 
-## Why?
-
-We like [Streamlit](https://streamlit.io/) but find it does [too much magic when it comes to state handling](https://github.com/zauberzeug/nicegui/issues/1#issuecomment-847413651).
-In search for an alternative nice library to write simple graphical user interfaces in Python we discovered [JustPy](https://justpy.io/).
-While it is too "low-level HTML" for our daily usage, it provides a great basis for NiceGUI.
-
-## Documentation and Examples
-
-The API reference is hosted at [https://nicegui.io](https://nicegui.io).
-It is [implemented with NiceGUI itself](https://github.com/zauberzeug/nicegui/blob/main/main.py).
-You may also have a look at the [examples folder](https://github.com/zauberzeug/nicegui/tree/main/examples) for more demonstrations of what you can do with NiceGUI.
-
-## Abstraction
-
-NiceGUI is based on [JustPy](https://justpy.io/) which is based on the ASGI framework [Starlette](https://www.starlette.io/) and the ASGI webserver [Uvicorn](https://www.uvicorn.org/).
-
 ## Deployment
 ## Deployment
 
 
 To deploy your NiceGUI app, you will need to execute your `main.py` (or whichever file contains your `ui.run(...)`) on your server infrastructure.
 To deploy your NiceGUI app, you will need to execute your `main.py` (or whichever file contains your `ui.run(...)`) on your server infrastructure.
-You can either install the [NiceGUI python package via pip](https://pypi.org/project/nicegui/) on the server or use our [pre-built Docker image](https://hub.docker.com/r/zauberzeug/nicegui) which contains all necessary dependencies.
+You can either install the [NiceGUI python package via pip](https://pypi.org/project/nicegui/) on the server or use our [pre-built Docker image](https://hub.docker.com/r/zauberzeug/nicegui) which contains all necessary dependencies (see above).
 For example you can use this `docker run` command to start the script `main.py` in the current directory on port 80:
 For example you can use this `docker run` command to start the script `main.py` in the current directory on port 80:
 
 
 ```bash
 ```bash

+ 157 - 54
api_docs_and_examples.py

@@ -5,33 +5,37 @@ from typing import Callable, Union
 
 
 import docutils.core
 import docutils.core
 
 
-from nicegui import ui
+from nicegui import globals, ui
+from nicegui.task_logger import create_task
+
+REGEX_H4 = re.compile(r'<h4.*?>(.*?)</h4>')
+SPECIAL_CHARACTERS = re.compile('[^(a-z)(A-Z)(0-9)-]')
 
 
 
 
 @contextmanager
 @contextmanager
-def example(content: Union[Callable, type, str]):
+def example(content: Union[Callable, type, str], first_col=4) -> None:
     callFrame = inspect.currentframe().f_back.f_back
     callFrame = inspect.currentframe().f_back.f_back
     begin = callFrame.f_lineno
     begin = callFrame.f_lineno
 
 
     def add_html_anchor(element: ui.html):
     def add_html_anchor(element: ui.html):
         html = element.content
         html = element.content
-        match = re.search(r'<h4.*?>(.*?)</h4>', html)
+        match = REGEX_H4.search(html)
         if not match:
         if not match:
             return
             return
-
-        headline_id = re.sub('[^(a-z)(A-Z)(0-9)-]', '_', match.groups()[0].strip()).lower()
+        headline = match.groups()[0].strip()
+        headline_id = SPECIAL_CHARACTERS.sub('_', headline).lower()
         if not headline_id:
         if not headline_id:
             return
             return
 
 
         icon = '<span class="material-icons">link</span>'
         icon = '<span class="material-icons">link</span>'
-        anchor = f'<a href="#{headline_id}" class="text-gray-300 hover:text-black">{icon}</a>'
+        anchor = f'<a href="reference#{headline_id}" class="text-gray-300 hover:text-black">{icon}</a>'
         html = html.replace('<h4', f'<h4 id="{headline_id}"', 1)
         html = html.replace('<h4', f'<h4 id="{headline_id}"', 1)
         html = html.replace('</h4>', f' {anchor}</h4>', 1)
         html = html.replace('</h4>', f' {anchor}</h4>', 1)
         element.view.inner_html = html
         element.view.inner_html = html
 
 
     with ui.row().classes('flex w-full'):
     with ui.row().classes('flex w-full'):
         if isinstance(content, str):
         if isinstance(content, str):
-            add_html_anchor(ui.markdown(content).classes('mr-8 w-4/12'))
+            add_html_anchor(ui.markdown(content).classes(f'mr-8 w-{first_col}/12'))
         else:
         else:
             doc = content.__doc__ or content.__init__.__doc__
             doc = content.__doc__ or content.__init__.__doc__
             html = docutils.core.publish_parts(doc, writer_name='html')['html_body']
             html = docutils.core.publish_parts(doc, writer_name='html')['html_body']
@@ -60,13 +64,56 @@ def example(content: Union[Callable, type, str]):
             code.insert(1, 'from nicegui import ui')
             code.insert(1, 'from nicegui import ui')
             if code[2].split()[0] not in ['from', 'import']:
             if code[2].split()[0] not in ['from', 'import']:
                 code.insert(2, '')
                 code.insert(2, '')
-            code.append('ui.run()')
+            for l, line in enumerate(code):
+                if line.startswith('# ui.'):
+                    code[l] = line[2:]
+                    break
+            else:
+                code.append('ui.run()')
             code.append('```')
             code.append('```')
             code = '\n'.join(code)
             code = '\n'.join(code)
-            ui.markdown(code).classes('mt-12 w-5/12 overflow-auto')
+            ui.markdown(code).classes(f'mt-12 w-{9-first_col}/12 overflow-auto')
+
+
+def create_intro() -> None:
+    # add docutils css to webpage
+    ui.add_head_html(docutils.core.publish_parts('', writer_name='html')['stylesheet'])
+
+    hello_world = '''#### Hello, World!
+
+Creating a user interface with NiceGUI is as simple as writing a single line of code.
+'''
+    with example(hello_world, first_col=2):
+        ui.label('Hello, world!')
+        ui.markdown('Have a look at the full <br/> [API reference](reference)!')
 
 
+    common_elements = '''#### Common UI Elements
+
+NiceGUI comes with a collection of commonly used UI elements.
+'''
+    with example(common_elements, first_col=2):
+        ui.button('Button', on_click=lambda: ui.notify('Click'))
+        ui.checkbox('Checkbox', on_change=lambda e: ui.notify('Checked' if e.value else 'Unchecked'))
+        ui.switch('Switch', on_change=lambda e: ui.notify('Switched' if e.value else 'Unswitched'))
+        ui.input('Text input', on_change=lambda e: ui.notify(e.value))
+        ui.radio(['A', 'B'], value='A', on_change=lambda e: ui.notify(e.value)).props('inline')
+        ui.select(['One', 'Two'], value='One', on_change=lambda e: ui.notify(e.value))
+        ui.link('And many more...', '/reference').classes('text-lg')
+
+    binding = '''#### Value Binding
+
+Binding values between UI elements or [to data models](http://127.0.0.1:8080/reference#bindings) is built into NiceGUI.
+'''
+    with example(binding, first_col=2):
+        slider = ui.slider(min=0, max=100, value=50)
+        ui.number('Value').bind_value(slider, 'value').classes('fit')
+
+    # HACK: this comment prevents another blank line sneaking into the example above
 
 
-async def create():
+
+def create_full() -> None:
+    # add docutils css to webpage
+    ui.add_head_html(docutils.core.publish_parts('', writer_name='html')['stylesheet'])
 
 
     ui.markdown('## API Documentation and Examples')
     ui.markdown('## API Documentation and Examples')
 
 
@@ -251,7 +298,7 @@ To overlay an SVG, make the `viewBox` exactly the size of the image and provide
 
 
         import numpy as np
         import numpy as np
 
 
-        line_plot = ui.line_plot(n=2, limit=20, figsize=(2.5, 1.8)) \
+        line_plot = ui.line_plot(n=2, limit=20, figsize=(2.5, 1.8), update_every=5) \
             .with_legend(['sin', 'cos'], loc='upper center', ncol=2)
             .with_legend(['sin', 'cos'], loc='upper center', ncol=2)
 
 
         def update_line_plot() -> None:
         def update_line_plot() -> None:
@@ -262,7 +309,7 @@ To overlay an SVG, make the `viewBox` exactly the size of the image and provide
             line_plot.push([now], [[y1], [y2]])
             line_plot.push([now], [[y1], [y2]])
 
 
         line_updates = ui.timer(0.1, update_line_plot, active=False)
         line_updates = ui.timer(0.1, update_line_plot, active=False)
-        ui.checkbox('active').bind_value(line_updates, 'active')
+        line_checkbox = ui.checkbox('active').bind_value(line_updates, 'active')
 
 
     with example(ui.scene):
     with example(ui.scene):
         with ui.scene(width=200, height=200) as scene:
         with ui.scene(width=200, height=200) as scene:
@@ -419,24 +466,22 @@ You can run a function or coroutine as a parallel task by passing it to one of t
 - `ui.on_startup`: Called when NiceGUI is started or restarted.
 - `ui.on_startup`: Called when NiceGUI is started or restarted.
 - `ui.on_shutdown`: Called when NiceGUI is shut down or restarted.
 - `ui.on_shutdown`: Called when NiceGUI is shut down or restarted.
 - `ui.on_connect`: Called when a client connects to NiceGUI. (Optional argument: Starlette request)
 - `ui.on_connect`: Called when a client connects to NiceGUI. (Optional argument: Starlette request)
-- `ui.on_page_ready`: Called when the page is ready and the websocket is connected. (Optional argument: socket)
+- `ui.on_page_ready`: Called when the page is ready and the websocket is connected (Optional argument: socket). See [Yield for Page Ready](#yield_for_page_ready) as an alternative.
 - `ui.on_disconnect`: Called when a client disconnects from NiceGUI. (Optional argument: socket)
 - `ui.on_disconnect`: Called when a client disconnects from NiceGUI. (Optional argument: socket)
 
 
 When NiceGUI is shut down or restarted, the startup tasks will be automatically canceled.
 When NiceGUI is shut down or restarted, the startup tasks will be automatically canceled.
 '''
 '''
     with example(lifecycle):
     with example(lifecycle):
         import asyncio
         import asyncio
-        import time
 
 
         l = ui.label()
         l = ui.label()
 
 
-        async def run_clock():
-            while True:
-                l.text = f'unix time: {time.time():.1f}'
+        async def countdown():
+            for i in [5, 4, 3, 2, 1, 0]:
+                l.text = f'{i}...' if i else 'Take-off!'
                 await asyncio.sleep(1)
                 await asyncio.sleep(1)
 
 
-        ui.on_startup(run_clock)
-        ui.on_connect(lambda: l.set_text('new connection'))
+        # ui.on_connect(countdown)
 
 
     with example(ui.timer):
     with example(ui.timer):
         from datetime import datetime
         from datetime import datetime
@@ -536,7 +581,7 @@ Note: You can also pass a `functools.partial` into the `on_click` property to wr
 
 
         ui.button('start async task', on_click=async_task)
         ui.button('start async task', on_click=async_task)
 
 
-    h3('Pages and Routes')
+    h3('Pages')
 
 
     with example(ui.page):
     with example(ui.page):
         @ui.page('/other_page')
         @ui.page('/other_page')
@@ -581,6 +626,25 @@ To make it "private" or to change other attributes like title, favicon etc. you
         ui.link('private page', private_page)
         ui.link('private page', private_page)
         ui.link('shared page', shared_page)
         ui.link('shared page', shared_page)
 
 
+    yield_page_ready = '''#### Yielding for Page-Ready
+
+This is a handy alternative to the `on_page_ready` callback (either as parameter of `@ui.page` or via `ui.on_page_ready` function).
+
+If a `yield` statement is provided in a page builder function, all code below that statement is executed after the page is ready.
+This allows you to execute JavaScript; which is only possible after the page has been loaded (see [#112](https://github.com/zauberzeug/nicegui/issues/112)).
+Also it is possible to do async stuff while the user already sees the content added before the yield statement.
+    '''
+    with example(yield_page_ready):
+        @ui.page('/yield_page_ready')
+        async def yield_page_ready():
+            ui.label('This text is displayed immediately.')
+            yield
+            ui.run_javascript('document.title = "JavaScript-Controlled Title")')
+            await asyncio.sleep(3)
+            ui.label('This text is displayed 3 seconds after the page has been fully loaded.')
+
+        ui.link('show page-ready code after yield', '/yield_page_ready')
+
     with example(ui.open):
     with example(ui.open):
         @ui.page('/yet_another_page')
         @ui.page('/yet_another_page')
         def yet_another_page():
         def yet_another_page():
@@ -589,38 +653,6 @@ To make it "private" or to change other attributes like title, favicon etc. you
 
 
         ui.button('REDIRECT', on_click=lambda e: ui.open(yet_another_page, e.socket))
         ui.button('REDIRECT', on_click=lambda e: ui.open(yet_another_page, e.socket))
 
 
-    add_route = '''#### Route
-
-Add a new route by calling `ui.add_route` with a starlette route including a path and a function to be called.
-Routed paths must start with a `'/'`.
-'''
-    with example(add_route):
-        import starlette
-
-        ui.add_route(starlette.routing.Route(
-            '/new/route', lambda _: starlette.responses.PlainTextResponse('Response')
-        ))
-
-        ui.link('Try the new route!', 'new/route')
-
-    get_decorator = '''#### Get decorator
-
-Syntactic sugar to add routes.
-Decorating a function with the `@ui.get` makes it available at the specified endpoint, e.g. `'/another/route/<id>'`.
-
-Path parameters can be passed to the request handler like with [FastAPI](https://fastapi.tiangolo.com/tutorial/path-params/).
-If type-annotated, they are automatically converted to `bool`, `int`, `float` and `complex` values.
-An optional `request` argument gives access to the complete request object.
-'''
-    with example(get_decorator):
-        from starlette import requests, responses
-
-        @ui.get('/another/route/{id}')
-        def produce_plain_response(id: str, request: requests.Request):
-            return responses.PlainTextResponse(f'{request.client.host} asked for id={id}')
-
-        ui.link('Try yet another route!', 'another/route/42')
-
     sessions = '''#### Sessions
     sessions = '''#### Sessions
 
 
 `ui.page` provides an optional `on_connect` argument to register a callback.
 `ui.page` provides an optional `on_connect` argument to register a callback.
@@ -666,5 +698,76 @@ The result of the execution is returned as a string.
         ui.button('fire and forget', on_click=alert)
         ui.button('fire and forget', on_click=alert)
         ui.button('receive result', on_click=get_date)
         ui.button('receive result', on_click=get_date)
 
 
-    # NOTE because the docs are added after inital page load, we need to manually trigger the jump tho the anchor
-    await ui.run_javascript('parts = document.URL.split("#"); window.location.hash = "#"; setTimeout(function(){ if (parts.length > 1) window.location.hash = "#" + parts[1]; }, 100); ')
+    h3('Routes')
+
+    with example(ui.get):
+        from starlette import requests, responses
+
+        @ui.get('/another/route/{id}')
+        def produce_plain_response(id: str, request: requests.Request):
+            return responses.PlainTextResponse(f'{request.client.host} asked for id={id}')
+
+        ui.link('Try yet another route!', 'another/route/42')
+
+    with example(ui.add_static_files):
+        ui.add_static_files('/examples', 'examples')
+        ui.link('Slideshow Example (raw file)', 'examples/slideshow/main.py')
+        with ui.image('examples/slideshow/slides/slide1.jpg'):
+            ui.label('first image from slideshow').classes('absolute-bottom text-subtitle2')
+
+    with example(ui.add_route):
+        import starlette
+
+        ui.add_route(starlette.routing.Route(
+            '/new/route', lambda _: starlette.responses.PlainTextResponse('Response')
+        ))
+
+        ui.link('Try the new route!', 'new/route')
+
+    h3('Configuration')
+
+    ui_run = '''#### ui.run
+
+You can call `ui.run()` with optional arguments:
+
+- `host` (default: `'0.0.0.0'`)
+- `port` (default: `8080`)
+- `title` (default: `'NiceGUI'`)
+- `favicon` (default: `'favicon.ico'`)
+- `dark`: whether to use Quasar's dark mode (default: `False`, use `None` for "auto" mode)
+- `main_page_classes`: configure Quasar classes of main page (default: `'q-ma-md column items-start'`)
+- `binding_refresh_interval`: time between binding updates (default: `0.1` seconds, bigger is more cpu friendly)
+- `show`: automatically open the ui in a browser tab (default: `True`)
+- `reload`: automatically reload the ui on file changes (default: `True`)
+- `uvicorn_logging_level`: logging level for uvicorn server (default: `'warning'`)
+- `uvicorn_reload_dirs`: string with comma-separated list for directories to be monitored (default is current working directory only)
+- `uvicorn_reload_includes`: string with comma-separated list of glob-patterns which trigger reload on modification (default: `'.py'`)
+- `uvicorn_reload_excludes`: string with comma-separated list of glob-patterns which should be ignored for reload (default: `'.*, .py[cod], .sw.*, ~*'`)
+- `exclude`: comma-separated string to exclude elements (with corresponding JavaScript libraries) to save bandwidth
+  (possible entries: chart, colors, custom_example, interactive_image, keyboard, log, joystick, scene, table)
+
+The environment variables `HOST` and `PORT` can also be used to configure NiceGUI.
+
+To avoid the potentially costly import of Matplotlib, you set the environment variable `MATPLOTLIB=false`.
+This will make `ui.plot` and `ui.line_plot` unavailable.
+'''
+    with example(ui_run):
+        ui.label('dark page on port 7000 without reloading')
+
+        # ui.run(dark=True, port=7000, reload=False)
+
+    # HACK: turn expensive line plot off after 10 seconds
+    def handle_change(self, msg):
+        def turn_off():
+            line_checkbox.value = False
+            ui.notify('Turning off that line plot to save resources on our live demo server. 😎')
+        line_checkbox.value = msg.value
+        if msg.value:
+            with globals.within_view(line_checkbox.view):
+                ui.timer(10.0, turn_off, once=True)
+        line_checkbox.update()
+        return False
+    line_checkbox.view.on('input', handle_change)
+
+    # HACK: start countdown here to avoid using global lifecycle hook
+    create_task(countdown(), name='countdown')

+ 18 - 0
examples/3d_scene/main.py

@@ -0,0 +1,18 @@
+#!/usr/bin/env python3
+import os
+
+from nicegui import ui
+
+ui.add_static_files('/static', f'{os.path.dirname(os.path.realpath(__file__))}/static')
+
+with ui.scene(width=1024, height=800) as scene:
+    scene.spot_light(distance=100, intensity=0.1).move(-10, 0, 10)
+    scene.stl('static/pikachu.stl').scale(0.1)
+    scene.move_camera(
+        -5, -3, 3,  # position
+        0, 0, 3,  # look at
+        0, 0, 1,  # up
+        0  # animation duration
+    )
+
+ui.run()

BIN
examples/3d_scene/static/pikachu.stl


+ 43 - 0
examples/ai_interface/main.py

@@ -0,0 +1,43 @@
+#!/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.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(args: UploadEventArguments):
+    transcription.text = 'Transcribing...'
+    model = replicate.models.get('openai/whisper')
+    prediction = await io_bound(model.predict, audio=io.BytesIO(args.files[0]))
+    text = prediction.get('transcription', 'no transcription')
+    transcription.set_text(f'result: "{text}"')
+
+
+async def generate_image():
+    image.source = 'https://dummyimage.com/600x400/ccc/000000.png&text=building+image...'
+    model = replicate.models.get('stability-ai/stable-diffusion')
+    prediction = await io_bound(model.predict, prompt=prompt.value)
+    image.source = prediction[0]
+
+# User Interface
+with ui.row().style('gap:10em'):
+    with ui.column():
+        ui.label('OpenAI Whisper (voice transcription)').classes('text-2xl')
+        ui.upload(on_upload=transcribe).style('width: 20em')
+        transcription = ui.label().classes('text-xl')
+    with ui.column():
+        ui.label('Stable Diffusion (image generator)').classes('text-2xl')
+        prompt = ui.input('prompt').style('width: 20em')
+        ui.button('Generate', on_click=generate_image).style('width: 15em')
+        image = ui.image().style('width: 60em')
+
+ui.run()

+ 0 - 5
examples/map/leaflet.py

@@ -72,8 +72,3 @@ class map(ui.card):
             });
             });
         </script>
         </script>
         ''')
         ''')
-
-# var target = L.latLng('53.44', '14.06'); // Fahrenwalde, Uckermark
-#                     map.setView(target, 13);
-#                     marker = L.marker(target);
-#                     marker.addTo(map);

+ 12 - 12
examples/map/main.py

@@ -1,24 +1,24 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
-
 from nicegui import ui
 from nicegui import ui
 
 
+# this module wraps the JavaScript lib leafletjs.com into an easy-to-use NiceGUI element
 import leaflet
 import leaflet
 
 
-locations = {
-    (52.5200, 13.4049): 'Berlin',
-    (40.7306, -74.0060): 'New York',
-    (39.9042, 116.4074): 'Beijing',
-    (35.6895, 139.6917): 'Tokyo',
-}
-selection = None
-
 
 
-@ui.page('/', on_page_ready=lambda: selection.set_value(next(iter(locations))))
+@ui.page('/')
 def main_page():
 def main_page():
-    # NOTE we need to use the on_page_ready event to make sure the page is loaded before we execute javascript
-    global selection
     map = leaflet.map()
     map = leaflet.map()
+    locations = {
+        (52.5200, 13.4049): 'Berlin',
+        (40.7306, -74.0060): 'New York',
+        (39.9042, 116.4074): 'Beijing',
+        (35.6895, 139.6917): 'Tokyo',
+    }
     selection = ui.select(locations, on_change=map.set_location).style('width: 10em')
     selection = ui.select(locations, on_change=map.set_location).style('width: 10em')
+    yield  # all code below is executed after page is ready
+    default_location = next(iter(locations))
+    # this will trigger the map.set_location event; which is js and must be run after page is ready
+    selection.set_value(default_location)
 
 
 
 
 ui.run()
 ui.run()

+ 43 - 8
main.py

@@ -1,17 +1,36 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
 import re
 import re
 
 
-import docutils.core
+import markdown2
 
 
 import api_docs_and_examples
 import api_docs_and_examples
 import traffic_tracking
 import traffic_tracking
 from nicegui import ui
 from nicegui import ui
+from nicegui.elements.markdown import Markdown
 
 
+with open('README.md') as f:
+    content = f.read()
+    content = re.sub(r'(?m)^\<img.*\n?', '', content)
+    # change absolute link on GitHub to relative link
+    content = content.replace('(https://nicegui.io/reference)', '(reference)')
+    README = Markdown.apply_tailwind(markdown2.markdown(content, extras=['fenced-code-blocks']))
 
 
-@ui.page('/', on_page_ready=api_docs_and_examples.create, on_connect=traffic_tracking.on_connect)
+
+async def go_to_anchor() -> None:
+    # NOTE because the docs are added after initial page load, we need to manually trigger the jump to the anchor
+    await ui.run_javascript('''
+        parts = document.URL.split("#");
+        console.log(parts);
+        if (parts.length > 1) {
+            console.log(window.location);
+            window.location = parts[0] + "reference#" + parts[1];
+            console.log(window.location);
+        }
+    ''')
+
+
+@ui.page('/', on_connect=traffic_tracking.on_connect, on_page_ready=go_to_anchor)
 async def index():
 async def index():
-    # add docutils css to webpage
-    ui.add_head_html(docutils.core.publish_parts('', writer_name='html')['stylesheet'])
     # avoid display:block for PyPI/Docker/GitHub badges
     # avoid display:block for PyPI/Docker/GitHub badges
     ui.add_head_html('<style>p a img {display: inline; vertical-align: baseline}</style>')
     ui.add_head_html('<style>p a img {display: inline; vertical-align: baseline}</style>')
 
 
@@ -21,11 +40,13 @@ async def index():
         '<a class="github-fork-ribbon" href="https://github.com/zauberzeug/nicegui" data-ribbon="Fork me on GitHub" title="Fork me on GitHub">Fork me on GitHub</a>'
         '<a class="github-fork-ribbon" href="https://github.com/zauberzeug/nicegui" data-ribbon="Fork me on GitHub" title="Fork me on GitHub">Fork me on GitHub</a>'
     )
     )
 
 
+    installation_start = README.find('<h2 class="text-4xl mb-3 mt-5">Installation</h2>')
+    documentation_start = README.find('The API reference is hosted at')
+    assert installation_start >= 0
+    assert documentation_start >= 0
+
     with ui.row().classes('flex w-full'):
     with ui.row().classes('flex w-full'):
-        with open('README.md', 'r') as file:
-            content = file.read()
-            content = re.sub(r'(?m)^\<img.*\n?', '', content)
-            ui.markdown(content).classes('w-6/12')
+        ui.html(README[:installation_start]).classes('w-6/12')
 
 
         with ui.column().classes('w-5/12 flex-center'):
         with ui.column().classes('w-5/12 flex-center'):
             width = 450
             width = 450
@@ -53,4 +74,18 @@ async def index():
             with ui.row().style('margin-top: 40px'):
             with ui.row().style('margin-top: 40px'):
                 traffic_tracking.chart().style(f'width:{width}px;height:250px')
                 traffic_tracking.chart().style(f'width:{width}px;height:250px')
 
 
+    ui.html(README[installation_start:documentation_start])
+
+    api_docs_and_examples.create_intro()
+    with ui.row().style('background-color: #e8f0fa; width: 100%; margin: 1em 0; padding: 1em 1em 0.5em 1em; font-size: large'):
+        ui.markdown('See the [API reference](/reference) for many more interactive examples!')
+
+    ui.html(README[documentation_start:])
+
+
+@ui.page('/reference')
+def reference():
+    api_docs_and_examples.create_full()
+
+
 ui.run()
 ui.run()

+ 93 - 0
nicegui/binding.py

@@ -101,3 +101,96 @@ class BindableProperty:
         update_views(propagate(owner, self.name))
         update_views(propagate(owner, self.name))
         if value_changed and self.on_change is not None:
         if value_changed and self.on_change is not None:
             self.on_change(owner, value)
             self.on_change(owner, value)
+
+
+class BindMixin:
+    """
+    Mixin providing bind methods for target object attributes.
+    """
+
+    def _bind_from(self, target_object, target_name, *, attr: str = 'value', backward=lambda x: x):
+        bind_from(self, attr, target_object, target_name, backward=backward)
+        return self
+
+    def _bind_to(self, target_object, target_name, *, attr: str = 'value', forward=lambda x: x):
+        bind_to(self, attr, target_object, target_name, forward=forward)
+        return self
+
+    def _bind(self, target_object, target_name, *, attr: str = 'value', forward=lambda x: x, backward=lambda x: x):
+        self._bind_from(target_object, target_name, attr=attr, backward=backward)
+        self._bind_to(target_object, target_name, attr=attr, forward=forward)
+        return self
+
+
+class BindTextMixin(BindMixin):
+    """
+    Mixin providing bind methods for attribute text.
+    """
+
+    def bind_text_to(self, target_object, target_name, forward=lambda x: x):
+        return super()._bind_to(attr='text', target_object=target_object, target_name=target_name, forward=forward)
+
+    def bind_text_from(self, target_object, target_name, backward=lambda x: x):
+        return super()._bind_from(attr='text', target_object=target_object, target_name=target_name, backward=backward)
+
+    def bind_text(self, target_object, target_name, forward=lambda x: x, backward=lambda x: x):
+        self.bind_text_from(target_object=target_object, target_name=target_name, backward=backward)
+        self.bind_text_to(target_object=target_object, target_name=target_name, forward=forward)
+        return self
+
+
+class BindValueMixin(BindMixin):
+    """
+    Mixin providing bind methods for attribute value.
+    """
+
+    def bind_value_to(self, target_object, target_name, forward=lambda x: x):
+        return super()._bind_to(attr='value', target_object=target_object, target_name=target_name, forward=forward)
+
+    def bind_value_from(self, target_object, target_name, backward=lambda x: x):
+        return super()._bind_from(attr='value', target_object=target_object, target_name=target_name, backward=backward)
+
+    def bind_value(self, target_object, target_name, forward=lambda x: x, backward=lambda x: x):
+        self.bind_value_from(target_object=target_object, target_name=target_name, backward=backward)
+        self.bind_value_to(target_object=target_object, target_name=target_name, forward=forward)
+        return self
+
+
+class BindVisibilityMixin(BindMixin):
+    """
+    Mixin providing bind methods for attribute visible.
+    """
+
+    def bind_visibility_to(self, target_object, target_name, forward=lambda x: x):
+        return super()._bind_to(attr='visible', target_object=target_object, target_name=target_name, forward=forward)
+
+    def bind_visibility_from(self, target_object, target_name, backward=lambda x: x, *, value=None):
+        if value is not None:
+            def backward(x): return x == value
+        return super()._bind_from(attr='visible', target_object=target_object, target_name=target_name,
+                                  backward=backward)
+
+    def bind_visibility(self, target_object, target_name, forward=lambda x: x, backward=lambda x: x, *, value=None):
+        if value is not None:
+            def backward(x): return x == value
+        self.bind_visibility_from(target_object=target_object, target_name=target_name, backward=backward)
+        self.bind_visibility_to(target_object=target_object, target_name=target_name, forward=forward)
+        return self
+
+
+class BindSourceMixin(BindMixin):
+    """
+    Mixin providing bind methods for attribute source.
+    """
+
+    def bind_source_to(self, target_object, target_name, forward=lambda x: x):
+        return super()._bind_to(attr='source', target_object=target_object, target_name=target_name, forward=forward)
+
+    def bind_source_from(self, target_object, target_name, backward=lambda x: x):
+        return super()._bind_from(attr='source', target_object=target_object, target_name=target_name,
+                                  backward=backward)
+
+    def bind_source(self, target_object, target_name, forward=lambda x: x, backward=lambda x: x):
+        self.bind_source_from(target_object=target_object, target_name=target_name, backward=backward)
+        self.bind_source_to(target_object=target_object, target_name=target_name, forward=forward)
+        return self

+ 2 - 15
nicegui/elements/button.py

@@ -2,12 +2,12 @@ from typing import Callable, Optional
 
 
 import justpy as jp
 import justpy as jp
 
 
-from ..binding import BindableProperty, bind_from, bind_to
+from ..binding import BindableProperty, BindTextMixin
 from ..events import ClickEventArguments, handle_event
 from ..events import ClickEventArguments, handle_event
 from .element import Element
 from .element import Element
 
 
 
 
-class Button(Element):
+class Button(Element, BindTextMixin):
     text = BindableProperty()
     text = BindableProperty()
 
 
     def __init__(self, text: str = '', *, on_click: Optional[Callable] = None):
     def __init__(self, text: str = '', *, on_click: Optional[Callable] = None):
@@ -30,16 +30,3 @@ class Button(Element):
 
 
     def set_text(self, text: str):
     def set_text(self, text: str):
         self.text = text
         self.text = text
-
-    def bind_text_to(self, target_object, target_name, forward=lambda x: x):
-        bind_to(self, 'text', target_object, target_name, forward=forward)
-        return self
-
-    def bind_text_from(self, target_object, target_name, backward=lambda x: x):
-        bind_from(self, 'text', target_object, target_name, backward=backward)
-        return self
-
-    def bind_text(self, target_object, target_name, forward=lambda x: x, backward=lambda x: x):
-        bind_from(self, 'text', target_object, target_name, backward=backward)
-        bind_to(self, 'text', target_object, target_name, forward=forward)
-        return self

+ 2 - 0
nicegui/elements/chart.py

@@ -11,6 +11,8 @@ class Chart(Element):
         """Chart
         """Chart
 
 
         An element to create a chart using `Highcharts <https://www.highcharts.com/>`_.
         An element to create a chart using `Highcharts <https://www.highcharts.com/>`_.
+        Updates can be pushed to the chart by changing the `options` property.
+        After data has changed, call the `update` method to refresh the chart.
 
 
         :param options: dictionary of Highcharts options
         :param options: dictionary of Highcharts options
         """
         """

+ 2 - 21
nicegui/elements/element.py

@@ -6,7 +6,7 @@ from typing import Dict, Optional
 import justpy as jp
 import justpy as jp
 
 
 from .. import globals
 from .. import globals
-from ..binding import BindableProperty, bind_from, bind_to
+from ..binding import BindableProperty, BindVisibilityMixin
 from ..page import Page, find_parent_view
 from ..page import Page, find_parent_view
 from ..task_logger import create_task
 from ..task_logger import create_task
 
 
@@ -16,7 +16,7 @@ def _handle_visibility_change(sender: Element, visible: bool) -> None:
     sender.update()
     sender.update()
 
 
 
 
-class Element:
+class Element(BindVisibilityMixin):
     visible = BindableProperty(on_change=_handle_visibility_change)
     visible = BindableProperty(on_change=_handle_visibility_change)
 
 
     def __init__(self, view: jp.HTMLBaseComponent):
     def __init__(self, view: jp.HTMLBaseComponent):
@@ -29,25 +29,6 @@ class Element:
 
 
         self.visible = True
         self.visible = True
 
 
-    def bind_visibility_to(self, target_object, target_name, forward=lambda x: x):
-        bind_to(self, 'visible', target_object, target_name, forward=forward)
-        return self
-
-    def bind_visibility_from(self, target_object, target_name, backward=lambda x: x, *, value=None):
-        if value is not None:
-            def backward(x): return x == value
-
-        bind_from(self, 'visible', target_object, target_name, backward=backward)
-        return self
-
-    def bind_visibility(self, target_object, target_name, forward=lambda x: x, backward=None, *, value=None):
-        if value is not None:
-            def backward(x): return x == value
-
-        bind_from(self, 'visible', target_object, target_name, backward=backward)
-        bind_to(self, 'visible', target_object, target_name, forward=forward)
-        return self
-
     def classes(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None):
     def classes(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None):
         '''HTML classes to modify the look of the element.
         '''HTML classes to modify the look of the element.
         Every class in the `remove` parameter will be removed from the element.
         Every class in the `remove` parameter will be removed from the element.

+ 2 - 15
nicegui/elements/expansion.py

@@ -2,11 +2,11 @@ from typing import Optional
 
 
 import justpy as jp
 import justpy as jp
 
 
-from ..binding import BindableProperty, bind_from, bind_to
+from ..binding import BindableProperty, BindTextMixin
 from .group import Group
 from .group import Group
 
 
 
 
-class Expansion(Group):
+class Expansion(Group, BindTextMixin):
     text = BindableProperty()
     text = BindableProperty()
 
 
     def __init__(self, text: str, *, icon: Optional[str] = None):
     def __init__(self, text: str, *, icon: Optional[str] = None):
@@ -25,16 +25,3 @@ class Expansion(Group):
 
 
     def set_text(self, text: str):
     def set_text(self, text: str):
         self.text = text
         self.text = text
-
-    def bind_text_to(self, target_object, target_name, forward=lambda x: x):
-        bind_to(self, 'text', target_object, target_name, forward=forward)
-        return self
-
-    def bind_text_from(self, target_object, target_name, backward=lambda x: x):
-        bind_from(self, 'text', target_object, target_name, backward=backward)
-        return self
-
-    def bind_text(self, target_object, target_name, forward=lambda x: x, backward=lambda x: x):
-        bind_from(self, 'text', target_object, target_name, backward=backward)
-        bind_to(self, 'text', target_object, target_name, forward=forward)
-        return self

+ 2 - 15
nicegui/elements/image.py

@@ -1,10 +1,10 @@
 import justpy as jp
 import justpy as jp
 
 
-from ..binding import BindableProperty, bind_from, bind_to
+from ..binding import BindableProperty, BindSourceMixin
 from .group import Group
 from .group import Group
 
 
 
 
-class Image(Group):
+class Image(Group, BindSourceMixin):
     source = BindableProperty()
     source = BindableProperty()
 
 
     def __init__(self, source: str = ''):
     def __init__(self, source: str = ''):
@@ -22,16 +22,3 @@ class Image(Group):
 
 
     def set_source(self, source: str):
     def set_source(self, source: str):
         self.source = source
         self.source = source
-
-    def bind_source_to(self, target_object, target_name, forward=lambda x: x):
-        bind_to(self, 'source', target_object, target_name, forward=forward)
-        return self
-
-    def bind_source_from(self, target_object, target_name, backward=lambda x: x):
-        bind_from(self, 'source', target_object, target_name, backward=backward)
-        return self
-
-    def bind_source(self, target_object, target_name, forward=lambda x: x, backward=lambda x: x):
-        bind_from(self, 'source', target_object, target_name, backward=backward)
-        bind_to(self, 'source', target_object, target_name, forward=forward)
-        return self

+ 11 - 2
nicegui/elements/interactive_image.py

@@ -5,6 +5,7 @@ from typing import Any, Callable, Dict, List, Optional
 
 
 from justpy import WebPage
 from justpy import WebPage
 
 
+from ..binding import BindableProperty, BindSourceMixin
 from ..events import MouseEventArguments, handle_event
 from ..events import MouseEventArguments, handle_event
 from ..routes import add_dependencies
 from ..routes import add_dependencies
 from .custom_view import CustomView
 from .custom_view import CustomView
@@ -30,9 +31,15 @@ class InteractiveImageView(CustomView):
         self.sockets = [s for s in self.sockets if s in page_sockets]
         self.sockets = [s for s in self.sockets if s in page_sockets]
 
 
 
 
-class InteractiveImage(Element):
+def _handle_source_change(sender: Element, source: str) -> None:
+    sender.view.options.source = source
+    sender.update()
 
 
-    def __init__(self, source: str, *,
+
+class InteractiveImage(Element, BindSourceMixin):
+    source = BindableProperty(on_change=_handle_source_change)
+
+    def __init__(self, source: str = '', *,
                  on_mouse: Optional[Callable] = None, events: List[str] = ['click'], cross: bool = False):
                  on_mouse: Optional[Callable] = None, events: List[str] = ['click'], cross: bool = False):
         """Interactive Image
         """Interactive Image
 
 
@@ -46,6 +53,8 @@ class InteractiveImage(Element):
         self.mouse_handler = on_mouse
         self.mouse_handler = on_mouse
         super().__init__(InteractiveImageView(source, self.handle_mouse, events, cross))
         super().__init__(InteractiveImageView(source, self.handle_mouse, events, cross))
 
 
+        self.source = source
+
     def handle_mouse(self, msg: Dict[str, Any]) -> Optional[bool]:
     def handle_mouse(self, msg: Dict[str, Any]) -> Optional[bool]:
         if self.mouse_handler is None:
         if self.mouse_handler is None:
             return False
             return False

+ 2 - 15
nicegui/elements/label.py

@@ -1,10 +1,10 @@
 import justpy as jp
 import justpy as jp
 
 
-from ..binding import BindableProperty, bind_from, bind_to
+from ..binding import BindableProperty, BindTextMixin
 from .element import Element
 from .element import Element
 
 
 
 
-class Label(Element):
+class Label(Element, BindTextMixin):
     text = BindableProperty()
     text = BindableProperty()
 
 
     def __init__(self, text: str = ''):
     def __init__(self, text: str = ''):
@@ -22,16 +22,3 @@ class Label(Element):
 
 
     def set_text(self, text: str):
     def set_text(self, text: str):
         self.text = text
         self.text = text
-
-    def bind_text_to(self, target_object, target_name, forward=lambda x: x):
-        bind_to(self, 'text', target_object, target_name, forward=forward)
-        return self
-
-    def bind_text_from(self, target_object, target_name, backward=lambda x: x):
-        bind_from(self, 'text', target_object, target_name, backward=backward)
-        return self
-
-    def bind_text(self, target_object, target_name, forward=lambda x: x, backward=lambda x: x):
-        bind_from(self, 'text', target_object, target_name, backward=backward)
-        bind_to(self, 'text', target_object, target_name, forward=forward)
-        return self

+ 2 - 16
nicegui/elements/value_element.py

@@ -2,13 +2,12 @@ from typing import Any, Callable, Dict, Optional
 
 
 import justpy as jp
 import justpy as jp
 
 
-from ..binding import BindableProperty, bind_from, bind_to
+from ..binding import BindableProperty, BindValueMixin
 from ..events import ValueChangeEventArguments, handle_event
 from ..events import ValueChangeEventArguments, handle_event
 from .element import Element
 from .element import Element
 
 
 
 
-class ValueElement(Element):
-
+class ValueElement(Element, BindValueMixin):
     value = BindableProperty(on_change=lambda sender, value: handle_event(
     value = BindableProperty(on_change=lambda sender, value: handle_event(
         sender.change_handler, ValueChangeEventArguments(sender=sender, socket=None, value=value)))
         sender.change_handler, ValueChangeEventArguments(sender=sender, socket=None, value=value)))
 
 
@@ -29,16 +28,3 @@ class ValueElement(Element):
         self.value = msg['value']
         self.value = msg['value']
         self.update()
         self.update()
         return False
         return False
-
-    def bind_value_to(self, target_object, target_name, *, forward=lambda x: x):
-        bind_to(self, 'value', target_object, target_name, forward=forward)
-        return self
-
-    def bind_value_from(self, target_object, target_name, *, backward=lambda x: x):
-        bind_from(self, 'value', target_object, target_name, backward=backward)
-        return self
-
-    def bind_value(self, target_object, target_name, *, forward=lambda x: x, backward=lambda x: x):
-        bind_from(self, 'value', target_object, target_name, backward=backward)
-        bind_to(self, 'value', target_object, target_name, forward=forward)
-        return self

+ 2 - 0
nicegui/lifecycle.py

@@ -14,6 +14,8 @@ def on_disconnect(self, handler: Union[Callable, Awaitable]):
 
 
 
 
 def on_startup(self, handler: Union[Callable, Awaitable]):
 def on_startup(self, handler: Union[Callable, Awaitable]):
+    if globals.state == globals.State.STARTED:
+        raise RuntimeError('Unable to register another startup handler. NiceGUI has already been started.')
     globals.startup_handlers.append(handler)
     globals.startup_handlers.append(handler)
 
 
 
 

+ 36 - 12
nicegui/page.py

@@ -3,9 +3,10 @@ from __future__ import annotations
 import asyncio
 import asyncio
 import inspect
 import inspect
 import time
 import time
+import types
 import uuid
 import uuid
 from functools import wraps
 from functools import wraps
-from typing import Callable, Dict, Optional
+from typing import Callable, Dict, Generator, Optional
 
 
 import justpy as jp
 import justpy as jp
 from addict import Dict as AdDict
 from addict import Dict as AdDict
@@ -45,6 +46,7 @@ class Page(jp.QuasarPage):
         self.css = css
         self.css = css
         self.connect_handler = on_connect
         self.connect_handler = on_connect
         self.page_ready_handler = on_page_ready
         self.page_ready_handler = on_page_ready
+        self.page_ready_generator: Optional[Generator[None, None, None]] = None
         self.disconnect_handler = on_disconnect
         self.disconnect_handler = on_disconnect
         self.delete_flag = not shared
         self.delete_flag = not shared
 
 
@@ -68,8 +70,13 @@ class Page(jp.QuasarPage):
         return self
         return self
 
 
     async def handle_page_ready(self, msg: AdDict) -> bool:
     async def handle_page_ready(self, msg: AdDict) -> bool:
-        if self.page_ready_handler:
-            with globals.within_view(self.view):
+        with globals.within_view(self.view):
+            if self.page_ready_generator is not None:
+                if isinstance(self.page_ready_generator, types.AsyncGeneratorType):
+                    await self.page_ready_generator.__anext__()
+                elif isinstance(self.page_ready_generator, types.GeneratorType):
+                    next(self.page_ready_generator)
+            if self.page_ready_handler:
                 arg_count = len(inspect.signature(self.page_ready_handler).parameters)
                 arg_count = len(inspect.signature(self.page_ready_handler).parameters)
                 is_coro = is_coroutine(self.page_ready_handler)
                 is_coro = is_coroutine(self.page_ready_handler)
                 if arg_count == 1:
                 if arg_count == 1:
@@ -151,7 +158,7 @@ class page:
         :param classes: tailwind classes for the container div (default: `'q-ma-md column items-start gap-4'`)
         :param classes: tailwind classes for the container div (default: `'q-ma-md column items-start gap-4'`)
         :param css: CSS definitions
         :param css: CSS definitions
         :param on_connect: optional function or coroutine which is called for each new client connection
         :param on_connect: optional function or coroutine which is called for each new client connection
-        :param on_page_ready: optional function or coroutine which is called when the websocket is connected
+        :param on_page_ready: optional function or coroutine which is called when the websocket is connected;  see `"Yield for Page Ready" <https://nicegui.io/#yield_for_page_ready>`_ as an alternative.
         :param on_disconnect: optional function or coroutine which is called when a client disconnects
         :param on_disconnect: optional function or coroutine which is called when a client disconnects
         :param shared: whether the page instance is shared between multiple clients (default: `False`)
         :param shared: whether the page instance is shared between multiple clients (default: `False`)
         """
         """
@@ -184,12 +191,23 @@ class page:
             with globals.within_view(self.page.view):
             with globals.within_view(self.page.view):
                 if 'request' in inspect.signature(func).parameters:
                 if 'request' in inspect.signature(func).parameters:
                     if self.shared:
                     if self.shared:
-                        globals.log.error('cannot use `request` argument in shared page; providing 404 error page')
-                        return error404()
+                        globals.log.error('Cannot use `request` argument in shared page')
+                        return error(501)
                     kwargs['request'] = request
                     kwargs['request'] = request
                 await self.connected(request)
                 await self.connected(request)
                 await self.header()
                 await self.header()
-                await func(*args, **kwargs) if is_coroutine(func) else func(*args, **kwargs)
+                result = await func(*args, **kwargs) if is_coroutine(func) else func(*args, **kwargs)
+                if isinstance(result, types.GeneratorType):
+                    if self.shared:
+                        globals.log.error('Yielding for page_ready is not supported on shared pages')
+                        return error(501)
+                    next(result)
+                if isinstance(result, types.AsyncGeneratorType):
+                    if self.shared:
+                        globals.log.error('Yielding for page_ready is not supported on shared pages')
+                        return error(501)
+                    await result.__anext__()
+                self.page.page_ready_generator = result
                 await self.footer()
                 await self.footer()
             return self.page
             return self.page
         builder = PageBuilder(decorated, self.shared)
         builder = PageBuilder(decorated, self.shared)
@@ -219,16 +237,22 @@ def find_parent_view() -> jp.HTMLBaseComponent:
     return view_stack[-1]
     return view_stack[-1]
 
 
 
 
-def error404() -> jp.QuasarPage:
-    title = globals.config.title if globals.config else '404'
+def error(status_code: int) -> jp.QuasarPage:
+    title = globals.config.title if globals.config else f'Error {status_code}'
     favicon = globals.config.favicon if globals.config else None
     favicon = globals.config.favicon if globals.config else None
     dark = globals.config.dark if globals.config else False
     dark = globals.config.dark if globals.config else False
     wp = jp.QuasarPage(title=title, favicon=favicon, dark=dark, tailwind=True)
     wp = jp.QuasarPage(title=title, favicon=favicon, dark=dark, tailwind=True)
     div = jp.Div(a=wp, classes='py-20 text-center')
     div = jp.Div(a=wp, classes='py-20 text-center')
     jp.Div(a=div, classes='text-8xl py-5', text='☹',
     jp.Div(a=div, classes='text-8xl py-5', text='☹',
            style='font-family: "Arial Unicode MS", "Times New Roman", Times, serif;')
            style='font-family: "Arial Unicode MS", "Times New Roman", Times, serif;')
-    jp.Div(a=div, classes='text-6xl py-5', text='404')
-    jp.Div(a=div, classes='text-xl py-5', text='This page doesn\'t exist.')
+    jp.Div(a=div, classes='text-6xl py-5', text=status_code)
+    if 400 <= status_code <= 499:
+        message = "This page doesn't exist"
+    elif 500 <= status_code <= 599:
+        message = 'Server error'
+    else:
+        message = 'Unknown error'
+    jp.Div(a=div, classes='text-xl py-5', text=message)
     return wp
     return wp
 
 
 
 
@@ -245,6 +269,6 @@ def init_auto_index_page() -> None:
 
 
 
 
 def create_page_routes() -> None:
 def create_page_routes() -> None:
-    jp.Route("/{path:path}", error404, last=True)
+    jp.Route("/{path:path}", lambda: error(404), last=True)
     for route, page_builder in globals.page_builders.items():
     for route, page_builder in globals.page_builders.items():
         page_builder.create_route(route)
         page_builder.create_route(route)

+ 23 - 9
nicegui/routes.py

@@ -14,26 +14,40 @@ from .helpers import is_coroutine
 
 
 
 
 def add_route(self, route: BaseRoute) -> None:
 def add_route(self, route: BaseRoute) -> None:
-    """
-    :param route: starlette route including a path and a function to be called
-    :return:
+    """Route
+
+    Adds a new `Starlette route <https://www.starlette.io/routing/>`_.
+    Routed paths must start with a slash "/".
+    Also see `@ui.get <https://nicegui.io/#get_decorator>`_ and `ui.add_static_files <https://nicegui.io/#get_decorator>`_
+    for more convenient ways to deliver non-UI content.
+
+    :param route: Starlette route including a path and a function to be called
     """
     """
     globals.app.routes.insert(0, route)
     globals.app.routes.insert(0, route)
 
 
 
 
 def add_static_files(self, path: str, directory: str) -> None:
 def add_static_files(self, path: str, directory: str) -> None:
-    """
-    :param path: string that starts with a '/'
+    """Static Files
+
+    Makes a local directory available at the specified endpoint, e.g. `'/static'`.
+    Do only put non-security-critical files in there, as they are accessible to everyone.
+
+    :param path: string that starts with a slash "/"
     :param directory: folder with static files to serve under the given path
     :param directory: folder with static files to serve under the given path
     """
     """
     add_route(None, Mount(path, app=StaticFiles(directory=directory)))
     add_route(None, Mount(path, app=StaticFiles(directory=directory)))
 
 
 
 
 def get(self, path: str):
 def get(self, path: str):
-    """
-    Use as a decorator for a function like @ui.get('/another/route/{id}').
-    :param path: string that starts with a '/'
-    :return:
+    """GET Decorator
+
+    Decorating a function with `@ui.get` makes it available at the specified endpoint, e.g. `'/another/route/<id>'`.
+
+    Path parameters can be passed to the request handler like with `FastAPI <https://fastapi.tiangolo.com/tutorial/path-params/>`_.
+    If type-annotated, they are automatically converted to `bool`, `int`, `float` and `complex` values.
+    An optional `request` argument gives access to the complete request object.
+
+    :param path: string that starts with a slash "/"
     """
     """
     *_, converters = compile_path(path)
     *_, converters = compile_path(path)
 
 

+ 1 - 0
release.dockerfile

@@ -5,6 +5,7 @@ RUN python -m pip install nicegui
 WORKDIR /app
 WORKDIR /app
 
 
 COPY main.py traffic_tracking.py api_docs_and_examples.py README.md ./ 
 COPY main.py traffic_tracking.py api_docs_and_examples.py README.md ./ 
+ADD examples ./examples
 
 
 EXPOSE 80
 EXPOSE 80
 
 

+ 2 - 1
tests/screen.py

@@ -20,6 +20,7 @@ IGNORED_CLASSES = ['row', 'column', 'q-card', 'q-field', 'q-field__label', 'q-in
 
 
 class Screen:
 class Screen:
     SCREENSHOT_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'screenshots')
     SCREENSHOT_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'screenshots')
+    UI_RUN_KWARGS = {'port': PORT, 'show': False, 'reload': False}
 
 
     def __init__(self, selenium: webdriver.Chrome) -> None:
     def __init__(self, selenium: webdriver.Chrome) -> None:
         self.selenium = selenium
         self.selenium = selenium
@@ -27,7 +28,7 @@ class Screen:
 
 
     def start_server(self) -> None:
     def start_server(self) -> None:
         '''Start the webserver in a separate thread. This is the equivalent of `ui.run()` in a normal script.'''
         '''Start the webserver in a separate thread. This is the equivalent of `ui.run()` in a normal script.'''
-        self.server_thread = threading.Thread(target=ui.run, kwargs={'port': PORT, 'show': False, 'reload': False})
+        self.server_thread = threading.Thread(target=ui.run, kwargs=self.UI_RUN_KWARGS)
         self.server_thread.start()
         self.server_thread.start()
 
 
     def stop_server(self) -> None:
     def stop_server(self) -> None:

+ 40 - 1
tests/test_pages.py

@@ -1,4 +1,5 @@
 import asyncio
 import asyncio
+from time import time
 from uuid import uuid4
 from uuid import uuid4
 
 
 import justpy.htmlcomponents
 import justpy.htmlcomponents
@@ -175,7 +176,8 @@ def test_shared_page_with_request_parameter_raises_exception(screen: Screen):
         ui.label('Hello, world!')
         ui.label('Hello, world!')
 
 
     screen.open('/')
     screen.open('/')
-    screen.should_contain("This page doesn't exist")
+    screen.should_contain('501')
+    screen.should_contain('Server error')
 
 
 
 
 def test_adding_elements_in_on_page_ready_event(screen: Screen):
 def test_adding_elements_in_on_page_ready_event(screen: Screen):
@@ -185,3 +187,40 @@ def test_adding_elements_in_on_page_ready_event(screen: Screen):
 
 
     screen.open('/')
     screen.open('/')
     screen.should_contain('Hello, world!')
     screen.should_contain('Hello, world!')
+
+
+def test_pageready_after_yield_on_async_page(screen: Screen):
+    @ui.page('/')
+    async def page():
+        ui.label('before')
+        yield
+        await asyncio.sleep(1)
+        ui.label('after')
+
+    screen.open('/')
+    screen.should_contain('before')
+    screen.should_not_contain('after')
+    screen.wait(1)
+    screen.should_contain('after')
+
+
+def test_pageready_after_yield_on_non_async_page(screen: Screen):
+    @ui.page('/')
+    def page():
+        t = time()
+        yield
+        ui.label(f'loading page took {time() - t:.2f} seconds')
+
+    screen.open('/')
+    timing = screen.find('loading page took')
+    assert 0 < float(timing.text.split()[-2]) < 1
+
+
+def test_pageready_after_yield_on_shared_page_raises_exception(screen: Screen):
+    @ui.page('/', shared=True)
+    def page():
+        yield
+
+    screen.open('/')
+    screen.should_contain('501')
+    screen.should_contain('Server error')

+ 2 - 2
traffic_tracking.py

@@ -63,14 +63,14 @@ def on_connect(request: Request) -> None:
     if any(s in agent for s in ('bot', 'spider', 'crawler', 'monitor', 'curl', 'wget', 'python-requests', 'kuma')):
     if any(s in agent for s in ('bot', 'spider', 'crawler', 'monitor', 'curl', 'wget', 'python-requests', 'kuma')):
         return
         return
     origin_url = request.headers.get('referer', 'unknown')
     origin_url = request.headers.get('referer', 'unknown')
-    print(f'new connection from {agent}, coming from {origin_url}', flush=True)
+    #print(f'new connection from {agent}, coming from {origin_url}', flush=True)
     def seconds_to_day(seconds: float) -> int: return int(seconds / 60 / 60 / 24)
     def seconds_to_day(seconds: float) -> int: return int(seconds / 60 / 60 / 24)
     #print(f'traffic data: {[datetime.fromtimestamp(day_to_milliseconds(t)/1000) for t in visits.keys()]}')
     #print(f'traffic data: {[datetime.fromtimestamp(day_to_milliseconds(t)/1000) for t in visits.keys()]}')
     today = seconds_to_day(time.time())
     today = seconds_to_day(time.time())
     visits[today] = visits.get(today, 0) + 1
     visits[today] = visits.get(today, 0) + 1
     referrers[today] = referrers.get(today, {})
     referrers[today] = referrers.get(today, {})
     referrers[today][origin_url] = referrers[today].get(origin_url, 0) + 1
     referrers[today][origin_url] = referrers[today].get(origin_url, 0) + 1
-    print(referrers, flush=True)
+    #print(referrers, flush=True)
     if today not in sessions:
     if today not in sessions:
         sessions[today] = set()
         sessions[today] = set()
     sessions[today].add(request.session_id)
     sessions[today].add(request.session_id)