Просмотр исходного кода

Merge branch 'main' into javascript

Falko Schindler 2 лет назад
Родитель
Сommit
dbed1a0271

+ 25 - 44
README.md

@@ -68,43 +68,40 @@ python3 main.py
 ```
 
 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
 
-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
 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`.
 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
 
 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:
 
 ```bash

+ 157 - 54
api_docs_and_examples.py

@@ -5,33 +5,37 @@ from typing import Callable, Union
 
 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
-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
     begin = callFrame.f_lineno
 
     def add_html_anchor(element: ui.html):
         html = element.content
-        match = re.search(r'<h4.*?>(.*?)</h4>', html)
+        match = REGEX_H4.search(html)
         if not match:
             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:
             return
 
         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' {anchor}</h4>', 1)
         element.view.inner_html = html
 
     with ui.row().classes('flex w-full'):
         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:
             doc = content.__doc__ or content.__init__.__doc__
             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')
             if code[2].split()[0] not in ['from', 'import']:
                 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 = '\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')
 
@@ -251,7 +298,7 @@ To overlay an SVG, make the `viewBox` exactly the size of the image and provide
 
         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)
 
         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_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 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_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_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)
 
 When NiceGUI is shut down or restarted, the startup tasks will be automatically canceled.
 '''
     with example(lifecycle):
         import asyncio
-        import time
 
         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)
 
-        ui.on_startup(run_clock)
-        ui.on_connect(lambda: l.set_text('new connection'))
+        # ui.on_connect(countdown)
 
     with example(ui.timer):
         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)
 
-    h3('Pages and Routes')
+    h3('Pages')
 
     with example(ui.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('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):
         @ui.page('/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))
 
-    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
 
 `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('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>
         ''')
-
-# 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
-
 from nicegui import ui
 
+# this module wraps the JavaScript lib leafletjs.com into an easy-to-use NiceGUI element
 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():
-    # 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()
+    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')
+    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()

+ 43 - 8
main.py

@@ -1,17 +1,36 @@
 #!/usr/bin/env python3
 import re
 
-import docutils.core
+import markdown2
 
 import api_docs_and_examples
 import traffic_tracking
 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():
-    # 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
     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>'
     )
 
+    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 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'):
             width = 450
@@ -53,4 +74,18 @@ async def index():
             with ui.row().style('margin-top: 40px'):
                 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()

+ 93 - 0
nicegui/binding.py

@@ -101,3 +101,96 @@ class BindableProperty:
         update_views(propagate(owner, self.name))
         if value_changed and self.on_change is not None:
             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
 
-from ..binding import BindableProperty, bind_from, bind_to
+from ..binding import BindableProperty, BindTextMixin
 from ..events import ClickEventArguments, handle_event
 from .element import Element
 
 
-class Button(Element):
+class Button(Element, BindTextMixin):
     text = BindableProperty()
 
     def __init__(self, text: str = '', *, on_click: Optional[Callable] = None):
@@ -30,16 +30,3 @@ class Button(Element):
 
     def set_text(self, text: str):
         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
 
         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
         """

+ 2 - 21
nicegui/elements/element.py

@@ -6,7 +6,7 @@ from typing import Dict, Optional
 import justpy as jp
 
 from .. import globals
-from ..binding import BindableProperty, bind_from, bind_to
+from ..binding import BindableProperty, BindVisibilityMixin
 from ..page import Page, find_parent_view
 from ..task_logger import create_task
 
@@ -16,7 +16,7 @@ def _handle_visibility_change(sender: Element, visible: bool) -> None:
     sender.update()
 
 
-class Element:
+class Element(BindVisibilityMixin):
     visible = BindableProperty(on_change=_handle_visibility_change)
 
     def __init__(self, view: jp.HTMLBaseComponent):
@@ -29,25 +29,6 @@ class Element:
 
         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):
         '''HTML classes to modify the look of 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
 
-from ..binding import BindableProperty, bind_from, bind_to
+from ..binding import BindableProperty, BindTextMixin
 from .group import Group
 
 
-class Expansion(Group):
+class Expansion(Group, BindTextMixin):
     text = BindableProperty()
 
     def __init__(self, text: str, *, icon: Optional[str] = None):
@@ -25,16 +25,3 @@ class Expansion(Group):
 
     def set_text(self, text: str):
         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
 
-from ..binding import BindableProperty, bind_from, bind_to
+from ..binding import BindableProperty, BindSourceMixin
 from .group import Group
 
 
-class Image(Group):
+class Image(Group, BindSourceMixin):
     source = BindableProperty()
 
     def __init__(self, source: str = ''):
@@ -22,16 +22,3 @@ class Image(Group):
 
     def set_source(self, source: str):
         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 ..binding import BindableProperty, BindSourceMixin
 from ..events import MouseEventArguments, handle_event
 from ..routes import add_dependencies
 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]
 
 
-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):
         """Interactive Image
 
@@ -46,6 +53,8 @@ class InteractiveImage(Element):
         self.mouse_handler = on_mouse
         super().__init__(InteractiveImageView(source, self.handle_mouse, events, cross))
 
+        self.source = source
+
     def handle_mouse(self, msg: Dict[str, Any]) -> Optional[bool]:
         if self.mouse_handler is None:
             return False

+ 2 - 15
nicegui/elements/label.py

@@ -1,10 +1,10 @@
 import justpy as jp
 
-from ..binding import BindableProperty, bind_from, bind_to
+from ..binding import BindableProperty, BindTextMixin
 from .element import Element
 
 
-class Label(Element):
+class Label(Element, BindTextMixin):
     text = BindableProperty()
 
     def __init__(self, text: str = ''):
@@ -22,16 +22,3 @@ class Label(Element):
 
     def set_text(self, text: str):
         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
 
-from ..binding import BindableProperty, bind_from, bind_to
+from ..binding import BindableProperty, BindValueMixin
 from ..events import ValueChangeEventArguments, handle_event
 from .element import Element
 
 
-class ValueElement(Element):
-
+class ValueElement(Element, BindValueMixin):
     value = BindableProperty(on_change=lambda sender, value: handle_event(
         sender.change_handler, ValueChangeEventArguments(sender=sender, socket=None, value=value)))
 
@@ -29,16 +28,3 @@ class ValueElement(Element):
         self.value = msg['value']
         self.update()
         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]):
+    if globals.state == globals.State.STARTED:
+        raise RuntimeError('Unable to register another startup handler. NiceGUI has already been started.')
     globals.startup_handlers.append(handler)
 
 

+ 36 - 12
nicegui/page.py

@@ -3,9 +3,10 @@ from __future__ import annotations
 import asyncio
 import inspect
 import time
+import types
 import uuid
 from functools import wraps
-from typing import Callable, Dict, Optional
+from typing import Callable, Dict, Generator, Optional
 
 import justpy as jp
 from addict import Dict as AdDict
@@ -45,6 +46,7 @@ class Page(jp.QuasarPage):
         self.css = css
         self.connect_handler = on_connect
         self.page_ready_handler = on_page_ready
+        self.page_ready_generator: Optional[Generator[None, None, None]] = None
         self.disconnect_handler = on_disconnect
         self.delete_flag = not shared
 
@@ -68,8 +70,13 @@ class Page(jp.QuasarPage):
         return self
 
     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)
                 is_coro = is_coroutine(self.page_ready_handler)
                 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 css: CSS definitions
         :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 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):
                 if 'request' in inspect.signature(func).parameters:
                     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
                 await self.connected(request)
                 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()
             return self.page
         builder = PageBuilder(decorated, self.shared)
@@ -219,16 +237,22 @@ def find_parent_view() -> jp.HTMLBaseComponent:
     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
     dark = globals.config.dark if globals.config else False
     wp = jp.QuasarPage(title=title, favicon=favicon, dark=dark, tailwind=True)
     div = jp.Div(a=wp, classes='py-20 text-center')
     jp.Div(a=div, classes='text-8xl py-5', text='☹',
            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
 
 
@@ -245,6 +269,6 @@ def init_auto_index_page() -> 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():
         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:
-    """
-    :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)
 
 
 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
     """
     add_route(None, Mount(path, app=StaticFiles(directory=directory)))
 
 
 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)
 

+ 1 - 0
release.dockerfile

@@ -5,6 +5,7 @@ RUN python -m pip install nicegui
 WORKDIR /app
 
 COPY main.py traffic_tracking.py api_docs_and_examples.py README.md ./ 
+ADD examples ./examples
 
 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:
     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:
         self.selenium = selenium
@@ -27,7 +28,7 @@ class Screen:
 
     def start_server(self) -> None:
         '''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()
 
     def stop_server(self) -> None:

+ 40 - 1
tests/test_pages.py

@@ -1,4 +1,5 @@
 import asyncio
+from time import time
 from uuid import uuid4
 
 import justpy.htmlcomponents
@@ -175,7 +176,8 @@ def test_shared_page_with_request_parameter_raises_exception(screen: Screen):
         ui.label('Hello, world!')
 
     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):
@@ -185,3 +187,40 @@ def test_adding_elements_in_on_page_ready_event(screen: Screen):
 
     screen.open('/')
     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')):
         return
     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)
     #print(f'traffic data: {[datetime.fromtimestamp(day_to_milliseconds(t)/1000) for t in visits.keys()]}')
     today = seconds_to_day(time.time())
     visits[today] = visits.get(today, 0) + 1
     referrers[today] = referrers.get(today, {})
     referrers[today][origin_url] = referrers[today].get(origin_url, 0) + 1
-    print(referrers, flush=True)
+    #print(referrers, flush=True)
     if today not in sessions:
         sessions[today] = set()
     sessions[today].add(request.session_id)