浏览代码

Merge pull request #86 from zauberzeug/#61-page-decorator

Using @ui.page decorator to setup pages
Falko Schindler 2 年之前
父节点
当前提交
fdd580cb84

+ 21 - 25
README.md

@@ -22,7 +22,6 @@ NiceGUI is available as [PyPI package](https://pypi.org/project/nicegui/), [Dock
 ## Features
 
 - browser-based graphical user interface
-- shared state between multiple browser windows
 - implicit reload on code change
 - standard GUI elements like label, button, checkbox, switch, slider, input, file upload, ...
 - simple grouping with rows, columns, cards and dialogs
@@ -31,13 +30,17 @@ NiceGUI is available as [PyPI package](https://pypi.org/project/nicegui/), [Dock
   - plot graphs and charts,
   - render 3D scenes,
   - get steering events via virtual joysticks
-  - annotate images
+  - annotate and overlay images
+  - interact with tables
+  - navigate foldable tree structures
 - built-in timer to refresh data in intervals (even every 10 ms)
 - straight-forward data binding to write even less code
 - notifications, dialogs and menus to provide state of the art user interaction
+- shared and individual web pages
 - ability to add custom routes and data responses
 - capture keyboard input for global shortcuts etc
 - customize look by defining primary, secondary and accent colors
+- live-cycle events and session data
 
 ## Installation
 
@@ -73,38 +76,30 @@ Full documentation can be found at [https://nicegui.io](https://nicegui.io).
 
 You can call `ui.run()` with optional arguments:
 
-<!-- prettier-ignore-start -->
-<!-- NOTE: to keep explicit underscores `\_` -->
-
 - `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)
-- `reload`: automatically reload the ui on file changes (default: `True`)
+- `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.*, ~*'`)
-- `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)
-- `exclude`: comma-separated string to exclude libraries (with corresponding elements) to save bandwidth and/or startup time:
-  - "aggrid" (`ui.table`)
-  - "colors" (`ui.colors`)
-  - "custom\_example" (`ui.custom_example`)
-  - "highcharts" (`ui.chart`)
-  - "interactive\_image" (`ui.interactive_image`)
-  - "keyboard" (`ui.keyboard`)
-  - "log" (`ui.log`)
-  - "matplotlib" (`ui.plot` and `ui.line_plot`)
-  - "nipple" (`ui.joystick`)
-  - "three" (`ui.scene`)
-
-<!-- prettier-ignore-end -->
 
 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.
+
+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.
+
 ## Docker
 
 You can use our [multi-arch Docker image](https://hub.docker.com/repository/docker/zauberzeug/nicegui) for pain-free installation:
@@ -120,12 +115,13 @@ 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 too "low-level HTML" for our daily usage it provides a great basis for "NiceGUI".
+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.
 
-## API
+## Documentation and Examples
 
-The API reference is hosted at [https://nicegui.io](https://nicegui.io) and is [implemented with NiceGUI itself](https://github.com/zauberzeug/nicegui/blob/main/main.py).
+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 [examples.py](https://github.com/zauberzeug/nicegui/tree/main/examples.py) for more demonstrations of what you can do with NiceGUI.
 
 ## Abstraction

+ 62 - 10
main.py

@@ -183,8 +183,7 @@ with example(ui.color_picker):
     button = ui.button(on_click=picker.open).props('icon=colorize')
 
 with example(ui.upload):
-    ui.upload(on_upload=lambda e: upload_result.set_text(e.files))
-    upload_result = ui.label()
+    ui.upload(on_upload=lambda e: ui.notify(f'{len(e.files[0])} bytes'))
 
 h3('Markdown and HTML')
 
@@ -584,23 +583,55 @@ with example(async_handlers):
 h3('Pages and Routes')
 
 with example(ui.page):
-    with ui.page('/other_page'):
+    @ui.page('/other_page')
+    def other_page():
         ui.label('Welcome to the other side')
         ui.link('Back to main page', '#page')
 
-    with ui.page('/dark_page', dark=True):
+    @ui.page('/dark_page', dark=True)
+    def dark_page():
         ui.label('Welcome to the dark side')
         ui.link('Back to main page', '#page')
 
-    ui.link('Visit other page', 'other_page')
-    ui.link('Visit dark page', 'dark_page')
+    ui.link('Visit other page', other_page)
+    ui.link('Visit dark page', dark_page)
+
+shared_and_private_pages = '''#### Shared and Private Pages
+
+By default, pages created with the `@ui.page` decorator are "private".
+Their content is re-created for each client.
+Thus, in the example to the right, the displayed ID changes when the browser reloads the page.
+
+With `shared=True` you can create a shared page.
+Its content is created once at startup and each client sees the *same* elements.
+Here, the displayed ID remains constant when the browser reloads the page.
+
+#### Index page
+
+All elements that are not created within a decorated page function are automatically added to a new, *shared* index page at route "/".
+To make it "private" or to change other attributes like title, favicon etc. you can wrap it in a page function with `@ui.page('/', ...)` decorator.
+'''
+with example(shared_and_private_pages):
+    from uuid import uuid4
+
+    @ui.page('/private_page')
+    async def private_page():
+        ui.label(f'private page with ID {uuid4()}')
+
+    @ui.page('/shared_page', shared=True)
+    async def shared_page():
+        ui.label(f'shared page with ID {uuid4()}')
+
+    ui.link('private page', private_page)
+    ui.link('shared page', shared_page)
 
 with example(ui.open):
-    with ui.page('/yet_another_page') as other:
+    @ui.page('/yet_another_page')
+    def yet_another_page():
         ui.label('Welcome to yet another page')
         ui.button('RETURN', on_click=lambda e: ui.open('#open', e.socket))
 
-    ui.button('REDIRECT', on_click=lambda e: ui.open(other, e.socket))
+    ui.button('REDIRECT', on_click=lambda e: ui.open(yet_another_page, e.socket))
 
 add_route = '''#### Route
 
@@ -655,9 +686,30 @@ with example(sessions):
         id_counter[request.session_id] += 1
         visits.set_text(f'{len(id_counter)} unique views ({sum(id_counter.values())} overall) since {creation}')
 
-    with ui.page('/session_demo', on_connect=handle_connection) as page:
+    @ui.page('/session_demo', on_connect=handle_connection)
+    def session_demo():
+        global visits
         visits = ui.label()
 
-    ui.link('Visit session demo', page)
+    ui.link('Visit session demo', session_demo)
+
+javascript = '''#### JavaScript
+
+With `ui.run_javascript()` you can run arbitrary JavaScript code on a page that is executed in the browser.
+The asynchronous function will return after sending the command.
+
+With `ui.await_javascript()` you can send a JavaScript command and wait for its response.
+The asynchronous function will only return after receiving the result.
+'''
+with example(javascript):
+    async def run_javascript():
+        await ui.run_javascript('alert("Hello!")')
+
+    async def await_javascript():
+        response = await ui.await_javascript('Date()')
+        ui.notify(f'Browser time: {response}')
+
+    ui.button('run JavaScript', on_click=run_javascript)
+    ui.button('await JavaScript', on_click=await_javascript)
 
 ui.run()

+ 2 - 2
nicegui/binding.py

@@ -6,7 +6,7 @@ from typing import Any, Callable, Optional, Set, Tuple
 
 from justpy.htmlcomponents import HTMLBaseComponent
 
-from .globals import config
+from . import globals
 from .task_logger import create_task
 
 bindings = defaultdict(list)
@@ -31,7 +31,7 @@ async def loop():
         update_views(visited_views)
         if time.time() - t > 0.01:
             logging.warning(f'binding update for {len(visited_views)} visited views took {time.time() - t:.3f} s')
-        await asyncio.sleep(config.binding_refresh_interval)
+        await asyncio.sleep(globals.config.binding_refresh_interval)
 
 
 async def update_views_async(views: Set[HTMLBaseComponent]):

+ 0 - 60
nicegui/config.py

@@ -1,11 +1,7 @@
-import ast
-import inspect
 import os
 from dataclasses import dataclass
 from typing import Optional
 
-from . import globals
-
 
 @dataclass
 class Config():
@@ -15,61 +11,5 @@ class Config():
     title: str = 'NiceGUI'
     favicon: str = 'favicon.ico'
     dark: Optional[bool] = False
-    reload: bool = True
-    show: bool = True
-    uvicorn_logging_level: str = 'warning'
-    uvicorn_reload_dirs: str = '.'
-    uvicorn_reload_includes: str = '*.py'
-    uvicorn_reload_excludes: str = '.*, .py[cod], .sw.*, ~*'
     main_page_classes: str = 'q-ma-md column items-start'
     binding_refresh_interval: float = 0.1
-    exclude: str = ''
-
-
-excluded_endings = (
-    '<string>',
-    'spawn.py',
-    'runpy.py',
-    os.path.join('debugpy', 'server', 'cli.py'),
-    os.path.join('debugpy', '__main__.py'),
-    'pydevd.py',
-    '_pydev_execfile.py',
-)
-for f in reversed(inspect.stack()):
-    if not any(f.filename.endswith(ending) for ending in excluded_endings):
-        filepath = f.filename
-        break
-else:
-    raise Exception('Could not find main script in stacktrace')
-
-try:
-    with open(filepath) as f:
-        source = f.read()
-except FileNotFoundError:
-    config = Config()
-else:
-    for node in ast.walk(ast.parse(source)):
-        try:
-            func = node.value.func
-            if func.value.id == 'ui' and func.attr == 'run':
-                args = {
-                    keyword.arg:
-                        keyword.value.n if isinstance(keyword.value, ast.Num) else
-                        keyword.value.s if isinstance(keyword.value, ast.Str) else
-                        keyword.value.value
-                    for keyword in node.value.keywords
-                }
-                config = Config(**args)
-                globals.pre_evaluation_succeeded = True
-                break
-        except AttributeError:
-            continue
-    else:
-        config = Config()
-
-os.environ['HOST'] = config.host
-os.environ['PORT'] = str(config.port)
-os.environ['STATIC_DIRECTORY'] = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'static')
-os.environ['TEMPLATES_DIRECTORY'] = os.path.join(os.environ['STATIC_DIRECTORY'], 'templates')
-
-globals.config = config

+ 10 - 1
nicegui/elements/chart.py

@@ -1,18 +1,27 @@
+import asyncio
 from typing import Dict
 
 import justpy as jp
 
+from ..task_logger import create_task
 from .element import Element
 
+jp.template_options['highcharts'] = False
+
 
 class Chart(Element):
+
     def __init__(self, options: Dict):
         """Chart
 
         An element to create a chart using `Highcharts <https://www.highcharts.com/>`_.
 
-        :param options: dictionary of highcharts options
+        :param options: dictionary of Highcharts options
         """
         view = jp.HighCharts(temp=False)
         view.options = self.options = jp.Dict(**options)
         super().__init__(view)
+
+        if not jp.template_options['highcharts'] and asyncio.get_event_loop().is_running():
+            create_task(self.page.run_javascript('location.reload()'))
+        jp.template_options['highcharts'] = True

+ 2 - 2
nicegui/elements/colors.py

@@ -1,12 +1,12 @@
+from ..routes import add_dependencies
 from .custom_view import CustomView
 from .element import Element
 
-CustomView.use(__file__)
-
 
 class ColorsView(CustomView):
 
     def __init__(self, primary, secondary, accent, positive, negative, info, warning):
+        add_dependencies(__file__)
         super().__init__('colors',
                          primary=primary,
                          secondary=secondary,

+ 2 - 2
nicegui/elements/custom_example.py

@@ -1,12 +1,12 @@
+from ..routes import add_dependencies
 from .custom_view import CustomView
 from .element import Element
 
-CustomView.use(__file__)
-
 
 class CustomExampleView(CustomView):
 
     def __init__(self, on_change):
+        add_dependencies(__file__)
         super().__init__('custom_example', value=0)
 
         self.on_change = on_change

+ 0 - 24
nicegui/elements/custom_view.py

@@ -1,9 +1,4 @@
-import os.path
-from typing import List
-
 import justpy as jp
-from starlette.responses import FileResponse
-from starlette.routing import Route
 
 
 class CustomView(jp.JustpyBaseComponent):
@@ -31,22 +26,3 @@ class CustomView(jp.JustpyBaseComponent):
             'style': self.style,
             'options': self.options,
         }
-
-    @staticmethod
-    def use(py_filepath: str, dependencies: List[str] = []):
-        vue_filepath = os.path.splitext(os.path.realpath(py_filepath))[0] + '.js'
-
-        for dependency in dependencies:
-            is_remote = dependency.startswith('http://') or dependency.startswith('https://')
-            src = dependency if is_remote else f'lib/{dependency}'
-            if src not in jp.component_file_list:
-                jp.component_file_list += [src]
-                if not is_remote:
-                    filepath = f'{os.path.dirname(vue_filepath)}/{src}'
-                    route = Route(f'/{src}', lambda _, filepath=filepath: FileResponse(filepath))
-                    jp.app.routes.insert(0, route)
-
-        if vue_filepath not in jp.component_file_list:
-            filename = os.path.basename(vue_filepath)
-            jp.app.routes.insert(0, Route(f'/{filename}', lambda _: FileResponse(vue_filepath)))
-            jp.component_file_list += [filename]

+ 3 - 7
nicegui/elements/element.py

@@ -5,7 +5,7 @@ import asyncio
 import justpy as jp
 
 from ..binding import BindableProperty, bind_from, bind_to
-from ..globals import view_stack
+from ..page import Page, get_current_view
 from ..task_logger import create_task
 
 
@@ -22,19 +22,15 @@ class Element:
     visible = BindableProperty(on_change=_handle_visibility_change)
 
     def __init__(self, view: jp.HTMLBaseComponent):
-        self.parent_view = view_stack[-1]
+        self.parent_view = get_current_view()
         self.parent_view.add(view)
         self.view = view
         assert len(self.parent_view.pages) == 1
-        self.page = list(self.parent_view.pages.values())[0]
+        self.page: Page = list(self.parent_view.pages.values())[0]
         self.view.add_page(self.page)
 
         self.visible = True
 
-        if len(view_stack) == 1 and asyncio.get_event_loop().is_running():
-            # NOTE: This is the main page. There won't be any context exit and thus no UI update. So let's do that here.
-            create_task(self.parent_view.update())
-
     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

+ 2 - 2
nicegui/elements/interactive_image.py

@@ -6,15 +6,15 @@ from typing import Any, Callable, Dict, List, Optional
 from justpy import WebPage
 
 from ..events import MouseEventArguments, handle_event
+from ..routes import add_dependencies
 from .custom_view import CustomView
 from .element import Element
 
-CustomView.use(__file__)
-
 
 class InteractiveImageView(CustomView):
 
     def __init__(self, source: str, on_mouse: Callable, events: List[str], cross: bool):
+        add_dependencies(__file__)
         super().__init__('interactive_image', source=source, events=events, cross=cross, svg_content='')
         self.allowed_events = ['onMouse', 'onConnect']
         self.initialize(onMouse=on_mouse, onConnect=self.on_connect)

+ 2 - 2
nicegui/elements/joystick.py

@@ -1,10 +1,9 @@
 from typing import Any, Callable, Optional
 
+from ..routes import add_dependencies
 from .custom_view import CustomView
 from .element import Element
 
-CustomView.use(__file__, ['nipplejs.min.js'])
-
 
 class JoystickView(CustomView):
 
@@ -13,6 +12,7 @@ class JoystickView(CustomView):
                  on_move: Optional[Callable],
                  on_end: Optional[Callable],
                  **options: Any):
+        add_dependencies(__file__, ['nipplejs.min.js'])
         super().__init__('joystick', **options)
 
         self.on_start = on_start

+ 2 - 2
nicegui/elements/keyboard.py

@@ -2,15 +2,15 @@ import traceback
 from typing import Callable, Dict, Optional
 
 from ..events import KeyboardAction, KeyboardKey, KeyboardModifiers, KeyEventArguments, handle_event
+from ..routes import add_dependencies
 from .custom_view import CustomView
 from .element import Element
 
-CustomView.use(__file__)
-
 
 class KeyboardView(CustomView):
 
     def __init__(self, on_key: Callable, repeating: bool):
+        add_dependencies(__file__)
         super().__init__('keyboard', active_js_events=['keydown', 'keyup'], repeating=repeating)
         self.allowed_events = ['keyboardEvent']
         self.style = 'display: none'

+ 5 - 5
nicegui/elements/link.py

@@ -1,22 +1,22 @@
-from typing import Union
+from typing import Callable, Union
 
 import justpy as jp
 
+from ..globals import find_route
 from .group import Group
-from .page import Page
 
 
 class Link(Group):
 
-    def __init__(self, text: str = '', target: Union[Page, str] = '#'):
+    def __init__(self, text: str = '', target: Union[Callable, str] = '#'):
         """Link
 
         Create a hyperlink.
 
         :param text: display text
-        :param target: page or string that is a an absolute URL or relative path from base URL
+        :param target: page function or string that is a an absolute URL or relative path from base URL
         """
-        href = target if isinstance(target, str) else target.route[1:]
+        href = target if isinstance(target, str) else find_route(target)[1:]
         view = jp.A(text=text, href=href, classes='underline text-blue', temp=False)
 
         super().__init__(view)

+ 2 - 2
nicegui/elements/log.py

@@ -8,16 +8,16 @@ from typing import Deque
 
 from justpy.htmlcomponents import WebPage
 
+from ..routes import add_dependencies
 from ..task_logger import create_task
 from .custom_view import CustomView
 from .element import Element
 
-CustomView.use(__file__)
-
 
 class LogView(CustomView):
 
     def __init__(self, lines: Deque[str], max_lines: int):
+        add_dependencies(__file__)
         super().__init__('log', max_lines=max_lines)
         self.lines = lines
         self.allowed_events = ['onConnect']

+ 6 - 6
nicegui/elements/open.py

@@ -1,13 +1,13 @@
-from typing import Optional, Union
+from typing import Callable, Optional, Union
 
 from justpy import WebPage
-from nicegui.elements.page import Page
 from starlette.websockets import WebSocket
 
+from ..globals import find_route
 from ..task_logger import create_task
 
 
-def open(self, target: Union[Page, str], socket: Optional[WebSocket] = None):
+def open(self, target: Union[Callable, str], socket: Optional[WebSocket] = None):
     """Open
 
     Can be used to programmatically trigger redirects for a specific client.
@@ -15,14 +15,14 @@ def open(self, target: Union[Page, str], socket: Optional[WebSocket] = None):
     Note that *all* clients (i.e. browsers) connected to the page will open the target URL *unless* a socket is specified.
     User events like button clicks provide such a socket.
 
-    :param target: page or string that is a an absolute URL or relative path from base URL
+    :param target: page function or string that is a an absolute URL or relative path from base URL
     :param socket: optional WebSocket defining the target client
     """
     create_task(open_async(self, target, socket), name='open_async')
 
 
-async def open_async(self, target: Union[Page, str], socket: Optional[WebSocket] = None):
-    path = target if isinstance(target, str) else target.route[1:]
+async def open_async(self, target: Union[Callable, str], socket: Optional[WebSocket] = None):
+    path = target if isinstance(target, str) else find_route(target)[1:]
     sockets = [socket] if socket else [s for socket_dict in WebPage.sockets.values() for s in socket_dict.values()]
     for socket in sockets:
         if not path:

+ 0 - 131
nicegui/elements/page.py

@@ -1,131 +0,0 @@
-import asyncio
-import inspect
-import time
-import uuid
-from typing import Callable, Optional
-
-import justpy as jp
-from addict import Dict
-from pygments.formatters import HtmlFormatter
-from starlette.requests import Request
-
-from ..globals import config, connect_handlers, disconnect_handlers, page_stack, view_stack
-from ..helpers import is_coroutine
-
-
-class Page(jp.QuasarPage):
-
-    def __init__(self,
-                 route: str,
-                 title: Optional[str] = None,
-                 *,
-                 favicon: Optional[str] = None,
-                 dark: Optional[bool] = ...,
-                 classes: str = 'q-ma-md column items-start',
-                 css: str = HtmlFormatter().get_style_defs('.codehilite'),
-                 on_connect: Optional[Callable] = None,
-                 on_page_ready: Optional[Callable] = None,
-                 on_disconnect: Optional[Callable] = None,
-                 ):
-        """Page
-
-        Creates a new page at the given path.
-
-        :param route: route of the new page (path must start with '/')
-        :param title: optional page title
-        :param favicon: optional favicon
-        :param dark: whether to use Quasar's dark mode (defaults to `dark` argument of `run` command)
-        :param classes: tailwind classes for the container div (default: `'q-ma-md column items-start'`)
-        :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_disconnect: optional function or coroutine which is called when a client disconnects
-        """
-        super().__init__()
-
-        self.delete_flag = False
-        self.title = title or config.title
-        self.favicon = favicon or config.favicon
-        self.dark = dark if dark is not ... else config.dark
-        self.tailwind = True  # use Tailwind classes instead of Quasars
-        self.css = css
-        self.connect_handler = on_connect
-        self.page_ready_handler = on_page_ready
-        self.disconnect_handler = on_disconnect
-
-        self.waiting_javascript_commands: dict[str, str] = {}
-        self.on('result_ready', self.handle_javascript_result)
-        self.on('page_ready', self.handle_page_ready)
-
-        self.view = jp.Div(a=self, classes=classes, style='row-gap: 1em', temp=False)
-        self.view.add_page(self)
-
-        self.route = route
-        jp.Route(route, self._route_function)
-
-    async def _route_function(self, request: Request):
-        for connect_handler in connect_handlers + ([self.connect_handler] if self.connect_handler else []):
-            arg_count = len(inspect.signature(connect_handler).parameters)
-            is_coro = is_coroutine(connect_handler)
-            if arg_count == 1:
-                await connect_handler(request) if is_coro else connect_handler(request)
-            elif arg_count == 0:
-                await connect_handler() if is_coro else connect_handler()
-            else:
-                raise ValueError(f'invalid number of arguments (0 or 1 allowed, got {arg_count})')
-        return self
-
-    async def handle_page_ready(self, msg: Dict) -> bool:
-        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:
-                await self.page_ready_handler(msg.websocket) if is_coro else self.page_ready_handler(msg.websocket)
-            elif arg_count == 0:
-                await self.page_ready_handler() if is_coro else self.page_ready_handler()
-            else:
-                raise ValueError(f'invalid number of arguments (0 or 1 allowed, got {arg_count})')
-        return False
-
-    async def on_disconnect(self, websocket=None) -> None:
-        for disconnect_handler in ([self.disconnect_handler] if self.disconnect_handler else []) + disconnect_handlers:
-            arg_count = len(inspect.signature(disconnect_handler).parameters)
-            is_coro = is_coroutine(disconnect_handler)
-            if arg_count == 1:
-                await disconnect_handler(websocket) if is_coro else disconnect_handler(websocket)
-            elif arg_count == 0:
-                await disconnect_handler() if is_coro else disconnect_handler()
-            else:
-                raise ValueError(f'invalid number of arguments (0 or 1 allowed, got {arg_count})')
-        await super().on_disconnect(websocket)
-
-    def __enter__(self):
-        page_stack.append(self)
-        view_stack.append(self.view)
-        return self
-
-    def __exit__(self, *_):
-        page_stack.pop()
-        view_stack.pop()
-
-    async def await_javascript(self, code: str, check_interval: float = 0.01, timeout: float = 1.0) -> str:
-        start_time = time.time()
-        request_id = str(uuid.uuid4())
-        await self.run_javascript(code, request_id=request_id)
-        while request_id not in self.waiting_javascript_commands:
-            if time.time() > start_time + timeout:
-                raise TimeoutError('JavaScript did not respond in time')
-            await asyncio.sleep(check_interval)
-        return self.waiting_javascript_commands.pop(request_id)
-
-    def handle_javascript_result(self, msg) -> bool:
-        self.waiting_javascript_commands[msg.request_id] = msg.result
-        return False
-
-
-def add_head_html(self, html: str) -> None:
-    page_stack[-1].head_html += html
-
-
-def add_body_html(self, html: str) -> None:
-    page_stack[-1].body_html += html

+ 10 - 10
nicegui/elements/scene.py

@@ -7,21 +7,13 @@ from justpy import WebPage
 
 from ..events import handle_event
 from ..globals import view_stack
+from ..page import Page
+from ..routes import add_dependencies
 from ..task_logger import create_task
 from .custom_view import CustomView
 from .element import Element
-from .page import Page
 from .scene_object3d import Object3D
 
-CustomView.use(__file__, [
-    'three.min.js',
-    'CSS2DRenderer.js',
-    'CSS3DRenderer.js',
-    'OrbitControls.js',
-    'STLLoader.js',
-    'tween.umd.min.js',
-])
-
 
 @dataclass
 class SceneCamera:
@@ -45,6 +37,14 @@ class SceneCamera:
 class SceneView(CustomView):
 
     def __init__(self, *, width: int, height: int, on_click: Optional[Callable]):
+        add_dependencies(__file__, [
+            'three.min.js',
+            'CSS2DRenderer.js',
+            'CSS3DRenderer.js',
+            'OrbitControls.js',
+            'STLLoader.js',
+            'tween.umd.min.js',
+        ])
         super().__init__('scene', width=width, height=height)
         self.on_click = on_click
         self.allowed_events = ['onConnect', 'onClick']

+ 9 - 0
nicegui/elements/table.py

@@ -1,11 +1,16 @@
+import asyncio
 from typing import Dict
 
 import justpy as jp
 
+from ..task_logger import create_task
 from .element import Element
 
+jp.template_options['aggrid'] = False
+
 
 class Table(Element):
+
     def __init__(self, options: Dict):
         """Table
 
@@ -16,3 +21,7 @@ class Table(Element):
         view = jp.AgGrid(temp=False)
         view.options = self.options = jp.Dict(**options)
         super().__init__(view)
+
+        if not jp.template_options['aggrid'] and asyncio.get_event_loop().is_running():
+            create_task(self.page.run_javascript('location.reload()'))
+        jp.template_options['aggrid'] = True

+ 13 - 5
nicegui/globals.py

@@ -2,7 +2,7 @@ from __future__ import annotations
 
 import asyncio
 import logging
-from typing import TYPE_CHECKING, Awaitable, Callable, List, Union
+from typing import TYPE_CHECKING, Awaitable, Callable, Dict, List, Optional, Union
 
 from uvicorn import Server
 
@@ -11,12 +11,12 @@ if TYPE_CHECKING:
     from starlette.applications import Starlette
 
     from .config import Config
-    from .elements.page import Page
+    from .elements.page import PageBuilder
 
 app: 'Starlette'
-config: 'Config'
+config: Optional['Config'] = None
 server: Server
-page_stack: List['Page'] = []
+page_builders: Dict[str, 'PageBuilder'] = {}
 view_stack: List['jp.HTMLBaseComponent'] = []
 tasks: List[asyncio.tasks.Task] = []
 log: logging.Logger = logging.getLogger('nicegui')
@@ -24,4 +24,12 @@ connect_handlers: List[Union[Callable, Awaitable]] = []
 disconnect_handlers: List[Union[Callable, Awaitable]] = []
 startup_handlers: List[Union[Callable, Awaitable]] = []
 shutdown_handlers: List[Union[Callable, Awaitable]] = []
-pre_evaluation_succeeded: bool = False
+has_auto_index_page: bool = False
+dependencies: Dict[str, List[str]] = {}
+
+
+def find_route(function: Callable) -> str:
+    routes = [route for route, page_builder in page_builders.items() if page_builder.function == function]
+    if not routes:
+        raise ValueError(f'Invalid page function {function}')
+    return routes[0]

+ 4 - 11
nicegui/nicegui.py

@@ -1,5 +1,4 @@
 # isort:skip_file
-from starlette.websockets import WebSocket
 from typing import Awaitable, Callable
 
 if True:  # NOTE: prevent formatter from mixing up these lines
@@ -11,6 +10,7 @@ if True:  # NOTE: prevent formatter from mixing up these lines
     builtins.print = print_backup
 
 from . import binding, globals
+from .page import create_page_routes, init_auto_index_page
 from .task_logger import create_task
 from .timer import Timer
 
@@ -26,7 +26,9 @@ async def patched_justpy_startup():
 
 
 @jp.app.on_event('startup')
-def startup():
+async def startup():
+    init_auto_index_page()
+    await create_page_routes()
     globals.tasks.extend(create_task(t.coro, name=t.name) for t in Timer.prepared_coroutines)
     Timer.prepared_coroutines.clear()
     globals.tasks.extend(create_task(t, name='startup task')
@@ -53,12 +55,3 @@ def safe_invoke(func: Callable):
 
 app = globals.app = jp.app
 ui = Ui()
-
-
-def handle_page_ready(socket: WebSocket):
-    create_task(page.update(socket))
-
-
-page = ui.page('/', classes=globals.config.main_page_classes, on_page_ready=handle_page_ready)
-page.__enter__()
-jp.justpy(lambda: page, start_server=False)

+ 232 - 0
nicegui/page.py

@@ -0,0 +1,232 @@
+from __future__ import annotations
+
+import asyncio
+import inspect
+import time
+import uuid
+from dataclasses import dataclass
+from functools import wraps
+from typing import Awaitable, Callable, Optional
+
+import justpy as jp
+from addict import Dict
+from pygments.formatters import HtmlFormatter
+from starlette.requests import Request
+
+from . import globals
+from .helpers import is_coroutine
+
+
+@dataclass
+class PageBuilder:
+    function: Callable[[], Awaitable[Page]]
+    shared: bool
+
+    _shared_page: Optional[Page] = None
+
+    async def build(self) -> None:
+        assert self.shared
+        self._shared_page = await self.function()
+
+    async def route_function(self, request: Request) -> Page:
+        page = self._shared_page if self.shared else await self.function()
+        return await page._route_function(request)
+
+
+class Page(jp.QuasarPage):
+
+    def __init__(self,
+                 title: Optional[str] = None,
+                 *,
+                 favicon: Optional[str] = None,
+                 dark: Optional[bool] = ...,
+                 classes: str = 'q-ma-md column items-start',
+                 css: str = HtmlFormatter().get_style_defs('.codehilite'),
+                 on_connect: Optional[Callable] = None,
+                 on_page_ready: Optional[Callable] = None,
+                 on_disconnect: Optional[Callable] = None,
+                 shared: bool = False,
+                 ):
+        super().__init__()
+
+        if globals.config:
+            self.title = title or globals.config.title
+            self.favicon = favicon or globals.config.favicon
+            self.dark = dark if dark is not ... else globals.config.dark
+        else:
+            self.title = title
+            self.favicon = favicon
+            self.dark = dark if dark is not ... else None
+        self.tailwind = True  # use Tailwind classes instead of Quasars
+        self.css = css
+        self.connect_handler = on_connect
+        self.page_ready_handler = on_page_ready
+        self.disconnect_handler = on_disconnect
+        self.delete_flag = not shared
+
+        self.waiting_javascript_commands: dict[str, str] = {}
+        self.on('result_ready', self.handle_javascript_result)
+        self.on('page_ready', self.handle_page_ready)
+
+        self.view = jp.Div(a=self, classes=classes, style='row-gap: 1em', temp=False)
+        self.view.add_page(self)
+
+    async def _route_function(self, request: Request):
+        for handler in globals.connect_handlers + ([self.connect_handler] if self.connect_handler else []):
+            arg_count = len(inspect.signature(handler).parameters)
+            is_coro = is_coroutine(handler)
+            if arg_count == 1:
+                await handler(request) if is_coro else handler(request)
+            elif arg_count == 0:
+                await handler() if is_coro else handler()
+            else:
+                raise ValueError(f'invalid number of arguments (0 or 1 allowed, got {arg_count})')
+        return self
+
+    async def handle_page_ready(self, msg: Dict) -> bool:
+        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:
+                await self.page_ready_handler(msg.websocket) if is_coro else self.page_ready_handler(msg.websocket)
+            elif arg_count == 0:
+                await self.page_ready_handler() if is_coro else self.page_ready_handler()
+            else:
+                raise ValueError(f'invalid number of arguments (0 or 1 allowed, got {arg_count})')
+        return False
+
+    async def on_disconnect(self, websocket=None) -> None:
+        for handler in globals.disconnect_handlers + ([self.disconnect_handler] if self.disconnect_handler else[]):
+            arg_count = len(inspect.signature(handler).parameters)
+            is_coro = is_coroutine(handler)
+            if arg_count == 1:
+                await handler(websocket) if is_coro else handler(websocket)
+            elif arg_count == 0:
+                await handler() if is_coro else handler()
+            else:
+                raise ValueError(f'invalid number of arguments (0 or 1 allowed, got {arg_count})')
+        await super().on_disconnect(websocket)
+
+    async def await_javascript(self, code: str, *, check_interval: float = 0.01, timeout: float = 1.0) -> str:
+        start_time = time.time()
+        request_id = str(uuid.uuid4())
+        await self.run_javascript(code, request_id=request_id)
+        while request_id not in self.waiting_javascript_commands:
+            if time.time() > start_time + timeout:
+                raise TimeoutError('JavaScript did not respond in time')
+            await asyncio.sleep(check_interval)
+        return self.waiting_javascript_commands.pop(request_id)
+
+    def handle_javascript_result(self, msg) -> bool:
+        self.waiting_javascript_commands[msg.request_id] = msg.result
+        return False
+
+
+def add_head_html(self, html: str) -> None:
+    for page in get_current_view().pages.values():
+        page.head_html += html
+
+
+def add_body_html(self, html: str) -> None:
+    for page in get_current_view().pages.values():
+        page.body_html += html
+
+
+async def run_javascript(self, code: str) -> None:
+    for page in get_current_view().pages.values():
+        await page.run_javascript(code)
+
+
+async def await_javascript(self, code: str, *, check_interval: float = 0.01, timeout: float = 1.0) -> None:
+    for page in get_current_view().pages.values():
+        return await page.await_javascript(code)
+
+
+def page(self,
+         route: str,
+         title: Optional[str] = None,
+         *,
+         favicon: Optional[str] = None,
+         dark: Optional[bool] = ...,
+         classes: str = 'q-ma-md column items-start',
+         css: str = HtmlFormatter().get_style_defs('.codehilite'),
+         on_connect: Optional[Callable] = None,
+         on_page_ready: Optional[Callable] = None,
+         on_disconnect: Optional[Callable] = None,
+         shared: bool = False,
+         ):
+    """Page
+
+    Creates a new page at the given route.
+
+    :param route: route of the new page (path must start with '/')
+    :param title: optional page title
+    :param favicon: optional favicon
+    :param dark: whether to use Quasar's dark mode (defaults to `dark` argument of `run` command)
+    :param classes: tailwind classes for the container div (default: `'q-ma-md column items-start'`)
+    :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_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`)
+    """
+    def decorator(func):
+        @wraps(func)
+        async def decorated():
+            page = Page(
+                title=title,
+                favicon=favicon,
+                dark=dark,
+                classes=classes,
+                css=css,
+                on_connect=on_connect,
+                on_page_ready=on_page_ready,
+                on_disconnect=on_disconnect,
+                shared=shared,
+            )
+            globals.view_stack.append(page.view)
+            await func() if is_coroutine(func) else func()
+            globals.view_stack.pop()
+            return page
+        globals.page_builders[route] = PageBuilder(decorated, shared)
+        return decorated
+    return decorator
+
+
+def get_current_view() -> jp.HTMLBaseComponent:
+    if not globals.view_stack:
+        page = Page(shared=True)
+        globals.view_stack.append(page.view)
+        globals.has_auto_index_page = True  # NOTE: this automatically created page will get some attributes at startup
+        jp.Route('/', page._route_function)
+    return globals.view_stack[-1]
+
+
+def error404() -> jp.QuasarPage:
+    wp = jp.QuasarPage(title=globals.config.title, favicon=globals.config.favicon,
+                       dark=globals.config.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.')
+    return wp
+
+
+def init_auto_index_page() -> None:
+    if not globals.has_auto_index_page:
+        return
+    page: Page = get_current_view().pages[0]
+    page.title = globals.config.title
+    page.favicon = globals.config.favicon
+    page.dark = globals.config.dark
+    page.view.classes = globals.config.main_page_classes
+
+
+async def create_page_routes() -> None:
+    jp.Route("/{path:path}", error404, last=True)
+
+    for route, page_builder in globals.page_builders.items():
+        if page_builder.shared:
+            await page_builder.build()
+        jp.Route(route, page_builder.route_function)

+ 39 - 1
nicegui/routes.py

@@ -1,12 +1,19 @@
+import asyncio
 import inspect
+import os.path
 from functools import wraps
+from typing import List
 
+import justpy as jp
 from starlette import requests, routing
-from starlette.routing import BaseRoute, Mount
+from starlette.responses import FileResponse
+from starlette.routing import BaseRoute, Mount, Route
 from starlette.staticfiles import StaticFiles
 
 from . import globals
 from .helpers import is_coroutine
+from .page import Page, get_current_view
+from .task_logger import create_task
 
 
 def add_route(self, route: BaseRoute) -> None:
@@ -53,3 +60,34 @@ def get(self, path: str):
         self.add_route(routing.Route(path, decorated))
         return decorated
     return decorator
+
+
+def add_dependencies(py_filepath: str, dependencies: List[str] = []) -> None:
+    if py_filepath in globals.dependencies:
+        return
+    globals.dependencies[py_filepath] = dependencies
+
+    vue_filepath = os.path.splitext(os.path.realpath(py_filepath))[0] + '.js'
+
+    for dependency in dependencies:
+        is_remote = dependency.startswith('http://') or dependency.startswith('https://')
+        src = dependency if is_remote else f'lib/{dependency}'
+        if src not in jp.component_file_list:
+            jp.component_file_list += [src]
+            if not is_remote:
+                filepath = f'{os.path.dirname(vue_filepath)}/{src}'
+                route = Route(f'/{src}', lambda _, filepath=filepath: FileResponse(filepath))
+                jp.app.routes.insert(0, route)
+
+    if vue_filepath not in jp.component_file_list:
+        filename = os.path.basename(vue_filepath)
+        jp.app.routes.insert(0, Route(f'/{filename}', lambda _: FileResponse(vue_filepath)))
+        jp.component_file_list += [filename]
+
+    if asyncio.get_event_loop().is_running():
+        # NOTE: if new dependencies are added after starting the server, we need to reload the page on connected clients
+        async def reload() -> None:
+            for page in get_current_view().pages.values():
+                assert isinstance(page, Page)
+                await page.await_javascript('location.reload()')
+        create_task(reload())

+ 45 - 70
nicegui/run.py

@@ -13,27 +13,60 @@ from . import globals
 from .config import Config
 
 
-def _start_server(config: Config) -> None:
-    if config.show:
-        webbrowser.open(f'http://{config.host if config.host != "0.0.0.0" else "127.0.0.1"}:{config.port}/')
+def run(self, *,
+        host: str = os.environ.get('HOST', '0.0.0.0'),
+        port: int = int(os.environ.get('PORT', '8080')),
+        title: str = 'NiceGUI',
+        favicon: str = 'favicon.ico',
+        dark: Optional[bool] = False,
+        main_page_classes: str = 'q-ma-md column items-start',
+        binding_refresh_interval: float = 0.1,
+        show: bool = True,
+        reload: bool = True,
+        uvicorn_logging_level: str = 'warning',
+        uvicorn_reload_dirs: str = '.',
+        uvicorn_reload_includes: str = '*.py',
+        uvicorn_reload_excludes: str = '.*, .py[cod], .sw.*, ~*',
+        ):
+    globals.config = Config(
+        host=host,
+        port=port,
+        title=title,
+        favicon=favicon,
+        dark=dark,
+        main_page_classes=main_page_classes,
+        binding_refresh_interval=binding_refresh_interval,
+    )
+    os.environ['HOST'] = globals.config.host
+    os.environ['PORT'] = str(globals.config.port)
+    os.environ['STATIC_DIRECTORY'] = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'static')
+    os.environ['TEMPLATES_DIRECTORY'] = os.path.join(os.environ['STATIC_DIRECTORY'], 'templates')
+
+    if inspect.stack()[-2].filename.endswith('spawn.py'):
+        return
+
+    if show:
+        webbrowser.open(f'http://{host if host != "0.0.0.0" else "127.0.0.1"}:{port}/')
 
     def split_args(args: str) -> List[str]:
         return args.split(',') if ',' in args else [args]
 
+    # NOTE: The following lines are basically a copy of `uvicorn.run`, but keep a reference to the `server`.
+
     config = uvicorn.Config(
-        'nicegui:app' if config.reload else globals.app,
-        host=config.host,
-        port=config.port,
+        'nicegui:app' if reload else globals.app,
+        host=host,
+        port=port,
         lifespan='on',
-        reload=config.reload,
-        reload_includes=split_args(config.uvicorn_reload_includes) if config.reload else None,
-        reload_excludes=split_args(config.uvicorn_reload_excludes) if config.reload else None,
-        reload_dirs=split_args(config.uvicorn_reload_dirs) if config.reload else None,
-        log_level=config.uvicorn_logging_level,
+        reload=reload,
+        reload_includes=split_args(uvicorn_reload_includes) if reload else None,
+        reload_excludes=split_args(uvicorn_reload_excludes) if reload else None,
+        reload_dirs=split_args(uvicorn_reload_dirs) if reload else None,
+        log_level=uvicorn_logging_level,
     )
     globals.server = uvicorn.Server(config=config)
 
-    if (config.reload or config.workers > 1) and not isinstance(config.app, str):
+    if (reload or config.workers > 1) and not isinstance(config.app, str):
         logging.warning('You must pass the application as an import string to enable "reload" or "workers".')
         sys.exit(1)
 
@@ -50,61 +83,3 @@ def _start_server(config: Config) -> None:
 
     if not globals.server.started and not config.should_reload and config.workers == 1:
         sys.exit(STARTUP_FAILURE)
-
-
-if globals.pre_evaluation_succeeded and globals.config.reload and not inspect.stack()[-2].filename.endswith('spawn.py'):
-    _start_server(globals.config)
-    sys.exit()
-
-
-def run(self, *,
-        host: str = os.environ.get('HOST', '0.0.0.0'),
-        port: int = int(os.environ.get('PORT', '8080')),
-        title: str = 'NiceGUI',
-        favicon: str = 'favicon.ico',
-        dark: Optional[bool] = False,
-        reload: bool = True,
-        show: bool = True,
-        uvicorn_logging_level: str = 'warning',
-        uvicorn_reload_dirs: str = '.',
-        uvicorn_reload_includes: str = '*.py',
-        uvicorn_reload_excludes: str = '.*, .py[cod], .sw.*, ~*',
-        main_page_classes: str = 'q-ma-md column items-start',
-        binding_refresh_interval: float = 0.1,
-        exclude: str = '',
-        ):
-    if globals.pre_evaluation_succeeded and globals.config.reload == True:
-        return  # server has already started after pre-evaluating ui.run()
-
-    globals.config.host = host
-    globals.config.port = port
-    globals.config.title = title
-    globals.config.favicon = favicon
-    globals.config.dark = dark
-    globals.config.reload = reload
-    globals.config.show = show
-    globals.config.uvicorn_logging_level = uvicorn_logging_level
-    globals.config.uvicorn_reload_dirs = uvicorn_reload_dirs
-    globals.config.uvicorn_reload_includes = uvicorn_reload_includes
-    globals.config.uvicorn_reload_excludes = uvicorn_reload_excludes
-    globals.config.main_page_classes = main_page_classes
-    globals.config.binding_refresh_interval = binding_refresh_interval
-
-    main_page = globals.page_stack[-1]
-    main_page.title = globals.config.title
-    main_page.favicon = globals.config.favicon
-    main_page.dark = globals.config.dark
-    main_page.view.classes = globals.config.main_page_classes
-
-    if inspect.stack()[-2].filename.endswith('spawn.py'):
-        return  # server is reloading
-
-    if not globals.pre_evaluation_succeeded:
-        if exclude or reload:
-            logging.warning('Failed to pre-evaluate ui.run().')
-        if exclude:
-            logging.warning('The `exclude` argument will be ignored.')
-        if reload:
-            logging.warning('Reloading main script...')
-
-    _start_server(globals.config)

+ 11 - 32
nicegui/ui.py

@@ -3,29 +3,33 @@ import os
 
 
 class Ui:
-    from .config import config  # NOTE: before run
     from .run import run  # NOTE: before justpy
 
-    _excludes = [word.strip().lower() for word in config.exclude.split(',')]
-    _excludes = [e[:-3] if e.endswith('.js') else e for e in _excludes]  # NOTE: for python <3.9 without removesuffix
-    os.environ['HIGHCHARTS'] = str('highcharts' not in _excludes)
-    os.environ['AGGRID'] = str('aggrid' not in _excludes)
+    from .page import page, add_head_html, add_body_html, run_javascript, await_javascript
+    from .update import update
 
     from .elements.button import Button as button
     from .elements.card import Card as card
     from .elements.card import CardSection as card_section
+    from .elements.chart import Chart as chart
     from .elements.checkbox import Checkbox as checkbox
     from .elements.color_input import ColorInput as color_input
     from .elements.color_picker import ColorPicker as color_picker
+    from .elements.colors import Colors as colors
     from .elements.column import Column as column
+    from .elements.custom_example import CustomExample as custom_example
     from .elements.dialog import Dialog as dialog
     from .elements.expansion import Expansion as expansion
     from .elements.html import Html as html
     from .elements.icon import Icon as icon
     from .elements.image import Image as image
     from .elements.input import Input as input
+    from .elements.interactive_image import InteractiveImage as interactive_image
+    from .elements.joystick import Joystick as joystick
+    from .elements.keyboard import Keyboard as keyboard
     from .elements.label import Label as label
     from .elements.link import Link as link
+    from .elements.log import Log as log
     from .elements.markdown import Markdown as markdown
     from .elements.menu import Menu as menu
     from .elements.menu_item import MenuItem as menu_item
@@ -33,45 +37,20 @@ class Ui:
     from .elements.notify import Notify as notify
     from .elements.number import Number as number
     from .elements.open import open, open_async
-    from .elements.page import Page as page, add_head_html, add_body_html
     from .elements.radio import Radio as radio
     from .elements.row import Row as row
+    from .elements.scene import Scene as scene
     from .elements.select import Select as select
     from .elements.slider import Slider as slider
     from .elements.switch import Switch as switch
     from .elements.table import Table as table
     from .elements.toggle import Toggle as toggle
     from .elements.tree import Tree as tree
-    from .elements.update import update
     from .elements.upload import Upload as upload
     from .lifecycle import on_connect, on_disconnect, on_shutdown, on_startup, shutdown
     from .routes import add_route, add_static_files, get
     from .timer import Timer as timer
 
-    if 'colors' not in _excludes:
-        from .elements.colors import Colors as colors
-
-    if 'custom_example' not in _excludes:
-        from .elements.custom_example import CustomExample as custom_example
-
-    if 'highcharts' not in _excludes:
-        from .elements.chart import Chart as chart
-
-    if 'interactive_image' not in _excludes:
-        from .elements.interactive_image import InteractiveImage as interactive_image
-
-    if 'keyboard' not in _excludes:
-        from .elements.keyboard import Keyboard as keyboard
-
-    if 'log' not in _excludes:
-        from .elements.log import Log as log
-
-    if 'matplotlib' not in _excludes:
+    if os.environ.get('MATPLOTLIB', 'true').lower() == 'true':
         from .elements.line_plot import LinePlot as line_plot
         from .elements.plot import Plot as plot
-
-    if 'nipple' not in _excludes:
-        from .elements.joystick import Joystick as joystick
-
-    if 'three' not in _excludes:
-        from .elements.scene import Scene as scene

+ 2 - 2
nicegui/elements/update.py → nicegui/update.py

@@ -1,8 +1,8 @@
 import asyncio
 from typing import List
 
-from ..task_logger import create_task
-from .element import Element
+from .elements.element import Element
+from .task_logger import create_task
 
 
 def update(self, *elements: List[Element]) -> None:

+ 0 - 1
traffic_tracking.py

@@ -44,7 +44,6 @@ class TrafficChard(ui.chart):
         agent = request.headers['user-agent'].lower()
         if any(s in agent for s in ('bot', 'spider', 'crawler', 'monitor', 'curl', 'wget', 'python-requests', 'kuma')):
             return
-        logging.warning(agent)
 
         def seconds_to_day(seconds: float) -> int: return int(seconds / 60 / 60 / 24)
         def day_to_milliseconds(day: int) -> float: return day * 24 * 60 * 60 * 1000