Bläddra i källkod

Merge branch 'main' into input-error

Falko Schindler 1 år sedan
förälder
incheckning
0f5a8a89df
100 ändrade filer med 2346 tillägg och 754 borttagningar
  1. 2 1
      .github/workflows/test.yml
  2. 2 2
      .gitignore
  3. 3 3
      CITATION.cff
  4. 1 0
      README.md
  5. 15 44
      examples/authentication/main.py
  6. 10 3
      examples/fastapi/frontend.py
  7. 57 0
      examples/lightbox/main.py
  8. 13 1
      examples/local_file_picker/local_file_picker.py
  9. 1 1
      examples/local_file_picker/main.py
  10. 13 24
      examples/menu_and_tabs/main.py
  11. 20 0
      examples/modularization/example_c.py
  12. 2 7
      examples/modularization/example_pages.py
  13. 5 1
      examples/modularization/main.py
  14. 1 1
      examples/modularization/theme.py
  15. 3 3
      examples/script_executor/main.py
  16. 6 5
      examples/single_page_app/main.py
  17. 2 2
      examples/sqlite_database/main.py
  18. 1 1
      examples/table_and_slots/main.py
  19. 32 40
      examples/todo_list/main.py
  20. 5 3
      fetch_tailwind.py
  21. 1 1
      fly.toml
  22. 17 12
      main.py
  23. 2 0
      mypy.ini
  24. 2 1
      nicegui/__init__.py
  25. 44 0
      nicegui/api_router.py
  26. 90 7
      nicegui/app.py
  27. 3 1
      nicegui/background_tasks.py
  28. 22 12
      nicegui/binding.py
  29. 2 2
      nicegui/client.py
  30. 0 33
      nicegui/colors.py
  31. 5 2
      nicegui/element.py
  32. 3 2
      nicegui/elements/aggrid.py
  33. 7 2
      nicegui/elements/audio.py
  34. 4 7
      nicegui/elements/avatar.py
  35. 4 5
      nicegui/elements/badge.py
  36. 8 4
      nicegui/elements/button.py
  37. 2 2
      nicegui/elements/color_input.py
  38. 3 6
      nicegui/elements/icon.py
  39. 5 2
      nicegui/elements/image.py
  40. 2 1
      nicegui/elements/input.py
  41. 4 3
      nicegui/elements/interactive_image.py
  42. 3 4
      nicegui/elements/knob.py
  43. 12 3
      nicegui/elements/link.py
  44. 5 1
      nicegui/elements/log.js
  45. 5 2
      nicegui/elements/log.py
  46. 1 1
      nicegui/elements/mermaid.py
  47. 41 0
      nicegui/elements/mixins/color_elements.py
  48. 8 0
      nicegui/elements/mixins/disableable_element.py
  49. 9 4
      nicegui/elements/mixins/source_element.py
  50. 4 4
      nicegui/elements/mixins/value_element.py
  51. 9 2
      nicegui/elements/mixins/visibility.py
  52. 5 7
      nicegui/elements/progress.py
  53. 4 4
      nicegui/elements/scene_object3d.py
  54. 31 7
      nicegui/elements/select.py
  55. 3 5
      nicegui/elements/spinner.py
  56. 1 1
      nicegui/elements/table.py
  57. 26 12
      nicegui/elements/tabs.py
  58. 7 5
      nicegui/elements/upload.py
  59. 7 2
      nicegui/elements/video.py
  60. 5 2
      nicegui/event_listener.py
  61. 9 7
      nicegui/events.py
  62. 36 16
      nicegui/favicon.py
  63. 2 2
      nicegui/functions/javascript.py
  64. 9 7
      nicegui/functions/refreshable.py
  65. 10 5
      nicegui/functions/timer.py
  66. 4 3
      nicegui/globals.py
  67. 72 2
      nicegui/helpers.py
  68. 130 1
      nicegui/native.py
  69. 66 9
      nicegui/native_mode.py
  70. 13 4
      nicegui/nicegui.py
  71. 212 0
      nicegui/observables.py
  72. 3 3
      nicegui/outbox.py
  73. 17 6
      nicegui/page.py
  74. 27 5
      nicegui/run.py
  75. 6 2
      nicegui/run_with.py
  76. 128 0
      nicegui/storage.py
  77. 5 3
      nicegui/tailwind.py
  78. 5 4
      nicegui/templates/index.html
  79. 187 260
      poetry.lock
  80. 2 0
      pyproject.toml
  81. 4 0
      test_startup.sh
  82. 5 0
      tests/conftest.py
  83. 2 1
      tests/requirements.txt
  84. 5 3
      tests/screen.py
  85. 31 0
      tests/test_api_router.py
  86. 24 0
      tests/test_events.py
  87. 92 0
      tests/test_favicon.py
  88. 3 0
      tests/test_helpers.py
  89. 18 0
      tests/test_link.py
  90. 9 0
      tests/test_log.py
  91. 121 0
      tests/test_observables.py
  92. 14 0
      tests/test_select.py
  93. 83 0
      tests/test_serving_files.py
  94. 134 0
      tests/test_storage.py
  95. 20 1
      tests/test_tabs.py
  96. 153 0
      website/build_search_index.py
  97. 9 3
      website/demo.py
  98. 59 89
      website/documentation.py
  99. 5 3
      website/documentation_tools.py
  100. 2 2
      website/more_documentation/audio_documentation.py

+ 2 - 1
.github/workflows/test.yml

@@ -9,7 +9,7 @@ jobs:
         python: ["3.7", "3.8", "3.9", "3.10", "3.11"]
         python: ["3.7", "3.8", "3.9", "3.10", "3.11"]
       fail-fast: false
       fail-fast: false
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
-    timeout-minutes: 20
+    timeout-minutes: 40
     steps:
     steps:
       - uses: actions/checkout@v3
       - uses: actions/checkout@v3
       - name: set up Python
       - name: set up Python
@@ -26,6 +26,7 @@ jobs:
           poetry install
           poetry install
           # install packages to run the examples
           # install packages to run the examples
           pip install opencv-python opencv-contrib-python-headless httpx replicate langchain openai simpy
           pip install opencv-python opencv-contrib-python-headless httpx replicate langchain openai simpy
+          pip install -r tests/requirements.txt
           # try fix issue with importlib_resources
           # try fix issue with importlib_resources
           pip install importlib-resources
           pip install importlib-resources
       - name: test startup
       - name: test startup

+ 2 - 2
.gitignore

@@ -5,7 +5,7 @@ dist
 /test.py
 /test.py
 *.pickle
 *.pickle
 tests/screenshots/
 tests/screenshots/
-
-# ignore local virtual environments
+tests/media/
 venv
 venv
 .idea
 .idea
+.nicegui/

+ 3 - 3
CITATION.cff

@@ -8,7 +8,7 @@ authors:
   given-names: Rodja
   given-names: Rodja
   orcid: https://orcid.org/0009-0009-4735-6227
   orcid: https://orcid.org/0009-0009-4735-6227
 title: 'NiceGUI: Web-based user interfaces with Python. The nice way.'
 title: 'NiceGUI: Web-based user interfaces with Python. The nice way.'
-version: v1.2.15
-date-released: '2023-05-27'
+version: v1.2.20
+date-released: '2023-06-12'
 url: https://github.com/zauberzeug/nicegui
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.7976420
+doi: 10.5281/zenodo.8029984

+ 1 - 0
README.md

@@ -43,6 +43,7 @@ NiceGUI is available as [PyPI package](https://pypi.org/project/nicegui/), [Dock
 - straight-forward data binding and refreshable functions to write even less code
 - straight-forward data binding and refreshable functions to write even less code
 - notifications, dialogs and menus to provide state of the art user interaction
 - notifications, dialogs and menus to provide state of the art user interaction
 - shared and individual web pages
 - shared and individual web pages
+- easy-to-use per-user and general persistence
 - ability to add custom routes and data responses
 - ability to add custom routes and data responses
 - capture keyboard input for global shortcuts etc.
 - capture keyboard input for global shortcuts etc.
 - customize look by defining primary, secondary and accent colors
 - customize look by defining primary, secondary and accent colors

+ 15 - 44
examples/authentication/main.py

@@ -1,71 +1,42 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
-'''This is only a very simple authentication example which stores session IDs in memory and does not do any password hashing.
+"""This is just a very simple authentication example.
 
 
 Please see the `OAuth2 example at FastAPI <https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/>`_  or
 Please see the `OAuth2 example at FastAPI <https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/>`_  or
-use the great `Authlib package <https://docs.authlib.org/en/v0.13/client/starlette.html#using-fastapi>`_ to implement a real authentication system.
-
+use the great `Authlib package <https://docs.authlib.org/en/v0.13/client/starlette.html#using-fastapi>`_ to implement a classing real authentication system.
 Here we just demonstrate the NiceGUI integration.
 Here we just demonstrate the NiceGUI integration.
-'''
-
-import os
-import uuid
-from typing import Dict
-
-from fastapi import Request
+"""
 from fastapi.responses import RedirectResponse
 from fastapi.responses import RedirectResponse
-from starlette.middleware.sessions import SessionMiddleware
 
 
 from nicegui import app, ui
 from nicegui import app, ui
 
 
-# put your your own secret key in an environment variable MY_SECRET_KEY
-app.add_middleware(SessionMiddleware, secret_key=os.environ.get('MY_SECRET_KEY', ''))
-
-# in reality users and session_info would be persistent (e.g. database, file, ...) and passwords obviously hashed
-users = [('user1', 'pass1'), ('user2', 'pass2')]
-session_info: Dict[str, Dict] = {}
-
-
-def is_authenticated(request: Request) -> bool:
-    return session_info.get(request.session.get('id'), {}).get('authenticated', False)
+# in reality users passwords would obviously need to be hashed
+passwords = {'user1': 'pass1', 'user2': 'pass2'}
 
 
 
 
 @ui.page('/')
 @ui.page('/')
-def main_page(request: Request) -> None:
-    if not is_authenticated(request):
+def main_page() -> None:
+    if not app.storage.user.get('authenticated', False):
         return RedirectResponse('/login')
         return RedirectResponse('/login')
-    session = session_info[request.session['id']]
     with ui.column().classes('absolute-center items-center'):
     with ui.column().classes('absolute-center items-center'):
-        ui.label(f'Hello {session["username"]}!').classes('text-2xl')
-        # NOTE we navigate to a new page here to be able to modify the session cookie (it is only editable while a request is en-route)
-        # see https://github.com/zauberzeug/nicegui/issues/527 for more details
-        ui.button('', on_click=lambda: ui.open('/logout')).props('outline round icon=logout')
+        ui.label(f'Hello {app.storage.user["username"]}!').classes('text-2xl')
+        ui.button(on_click=lambda: (app.storage.user.clear(), ui.open('/login')), icon='logout').props('outline round')
 
 
 
 
 @ui.page('/login')
 @ui.page('/login')
-def login(request: Request) -> None:
+def login() -> None:
     def try_login() -> None:  # local function to avoid passing username and password as arguments
     def try_login() -> None:  # local function to avoid passing username and password as arguments
-        if (username.value, password.value) in users:
-            session_info[request.session['id']] = {'username': username.value, 'authenticated': True}
+        if passwords.get(username.value) == password.value:
+            app.storage.user.update({'username': username.value, 'authenticated': True})
             ui.open('/')
             ui.open('/')
         else:
         else:
             ui.notify('Wrong username or password', color='negative')
             ui.notify('Wrong username or password', color='negative')
 
 
-    if is_authenticated(request):
+    if app.storage.user.get('authenticated', False):
         return RedirectResponse('/')
         return RedirectResponse('/')
-    request.session['id'] = str(uuid.uuid4())  # NOTE this stores a new session ID in the cookie of the client
     with ui.card().classes('absolute-center'):
     with ui.card().classes('absolute-center'):
         username = ui.input('Username').on('keydown.enter', try_login)
         username = ui.input('Username').on('keydown.enter', try_login)
-        password = ui.input('Password').props('type=password').on('keydown.enter', try_login)
+        password = ui.input('Password').on('keydown.enter', try_login).props('type=password')
         ui.button('Log in', on_click=try_login)
         ui.button('Log in', on_click=try_login)
 
 
 
 
-@ui.page('/logout')
-def logout(request: Request) -> None:
-    if is_authenticated(request):
-        session_info.pop(request.session['id'])
-        request.session['id'] = None
-        return RedirectResponse('/login')
-    return RedirectResponse('/')
-
-
-ui.run()
+ui.run(storage_secret='THIS_NEEDS_TO_BE_CHANGED')

+ 10 - 3
examples/fastapi/frontend.py

@@ -1,11 +1,18 @@
 from fastapi import FastAPI
 from fastapi import FastAPI
 
 
-from nicegui import ui
+from nicegui import app, ui
 
 
 
 
-def init(app: FastAPI) -> None:
+def init(fastapi_app: FastAPI) -> None:
     @ui.page('/show')
     @ui.page('/show')
     def show():
     def show():
         ui.label('Hello, FastAPI!')
         ui.label('Hello, FastAPI!')
 
 
-    ui.run_with(app)
+        # NOTE dark mode will be persistent for each user across tabs and server restarts
+        ui.dark_mode().bind_value(app.storage.user, 'dark_mode')
+        ui.checkbox('dark mode').bind_value(app.storage.user, 'dark_mode')
+
+    ui.run_with(
+        fastapi_app,
+        storage_secret='pick your private secret here',  # NOTE setting a secret is optional but allows for persistent storage per user
+    )

+ 57 - 0
examples/lightbox/main.py

@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+from typing import List
+
+import httpx
+
+from nicegui import events, ui
+
+
+class Lightbox:
+    """A thumbnail gallery where each image can be clicked to enlarge.
+    Inspired by https://lokeshdhakar.com/projects/lightbox2/.
+    """
+
+    def __init__(self) -> None:
+        with ui.dialog().props('maximized').classes('bg-black') as self.dialog:
+            ui.keyboard(self._on_key)
+            self.large_image = ui.image().props('no-spinner')
+        self.image_list: List[str] = []
+
+    def add_image(self, thumb_url: str, orig_url: str) -> ui.image:
+        """Place a thumbnail image in the UI and make it clickable to enlarge."""
+        self.image_list.append(orig_url)
+        with ui.button(on_click=lambda: self._open(orig_url)).props('flat dense square'):
+            return ui.image(thumb_url)
+
+    def _on_key(self, event_args: events.KeyEventArguments) -> None:
+        if not event_args.action.keydown:
+            return
+        if event_args.key.escape:
+            self.dialog.close()
+        image_index = self.image_list.index(self.large_image.source)
+        if event_args.key.arrow_left and image_index > 0:
+            self._open(self.image_list[image_index - 1])
+        if event_args.key.arrow_right and image_index < len(self.image_list) - 1:
+            self._open(self.image_list[image_index + 1])
+
+    def _open(self, url: str) -> None:
+        self.large_image.set_source(url)
+        self.dialog.open()
+
+
+@ui.page('/')
+async def page():
+    lightbox = Lightbox()
+    async with httpx.AsyncClient() as client:  # using async httpx instead of sync requests to avoid blocking the event loop
+        images = await client.get('https://picsum.photos/v2/list?page=4&limit=30')
+    with ui.row().classes('w-full'):
+        for image in images.json():  # picsum returns a list of images as json data
+            # we can use the image ID to construct the image URLs
+            image_base_url = f'https://picsum.photos/id/{image["id"]}'
+            # the lightbox allows us to add images which can be opened in a full screen dialog
+            lightbox.add_image(
+                thumb_url=f'{image_base_url}/300/200',
+                orig_url=f'{image_base_url}/{image["width"]}/{image["height"]}',
+            ).classes('w-[300px] h-[200px]')
+
+ui.run()

+ 13 - 1
examples/local_file_picker/local_file_picker.py

@@ -1,3 +1,4 @@
+import platform
 from pathlib import Path
 from pathlib import Path
 from typing import Dict, Optional
 from typing import Dict, Optional
 
 
@@ -27,6 +28,7 @@ class local_file_picker(ui.dialog):
         self.show_hidden_files = show_hidden_files
         self.show_hidden_files = show_hidden_files
 
 
         with self, ui.card():
         with self, ui.card():
+            self.add_drives_toggle()
             self.grid = ui.aggrid({
             self.grid = ui.aggrid({
                 'columnDefs': [{'field': 'name', 'headerName': 'File'}],
                 'columnDefs': [{'field': 'name', 'headerName': 'File'}],
                 'rowSelection': 'multiple' if multiple else 'single',
                 'rowSelection': 'multiple' if multiple else 'single',
@@ -36,6 +38,16 @@ class local_file_picker(ui.dialog):
                 ui.button('Ok', on_click=self._handle_ok)
                 ui.button('Ok', on_click=self._handle_ok)
         self.update_grid()
         self.update_grid()
 
 
+    def add_drives_toggle(self):
+        if platform.system() == 'Windows':
+            import win32api
+            drives = win32api.GetLogicalDriveStrings().split('\000')[:-1]
+            self.drives_toggle = ui.toggle(drives, value=drives[0], on_change=self.update_drive)
+
+    def update_drive(self):
+        self.path = Path(self.drives_toggle.value).expanduser()
+        self.update_grid()
+
     def update_grid(self) -> None:
     def update_grid(self) -> None:
         paths = list(self.path.glob('*'))
         paths = list(self.path.glob('*'))
         if not self.show_hidden_files:
         if not self.show_hidden_files:
@@ -58,7 +70,7 @@ class local_file_picker(ui.dialog):
             })
             })
         self.grid.update()
         self.grid.update()
 
 
-    async def handle_double_click(self, msg: Dict) -> None:
+    def handle_double_click(self, msg: Dict) -> None:
         self.path = Path(msg['args']['data']['path'])
         self.path = Path(msg['args']['data']['path'])
         if self.path.is_dir():
         if self.path.is_dir():
             self.update_grid()
             self.update_grid()

+ 1 - 1
examples/local_file_picker/main.py

@@ -8,6 +8,6 @@ async def pick_file() -> None:
     result = await local_file_picker('~', multiple=True)
     result = await local_file_picker('~', multiple=True)
     ui.notify(f'You chose {result}')
     ui.notify(f'You chose {result}')
 
 
-ui.button('Choose file', on_click=pick_file).props('icon=folder')
+ui.button('Choose file', on_click=pick_file, icon='folder')
 
 
 ui.run()
 ui.run()

+ 13 - 24
examples/menu_and_tabs/main.py

@@ -1,24 +1,12 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
-from typing import Dict
-
 from nicegui import ui
 from nicegui import ui
 
 
-tab_names = ['A', 'B', 'C']
-
-# necessary until we improve native support for tabs (https://github.com/zauberzeug/nicegui/issues/251)
-
-
-def switch_tab(msg: Dict) -> None:
-    name = msg['args']
-    tabs.props(f'model-value={name}')
-    panels.props(f'model-value={name}')
-
-
 with ui.header().classes(replace='row items-center') as header:
 with ui.header().classes(replace='row items-center') as header:
     ui.button(on_click=lambda: left_drawer.toggle()).props('flat color=white icon=menu')
     ui.button(on_click=lambda: left_drawer.toggle()).props('flat color=white icon=menu')
-    with ui.element('q-tabs').on('update:model-value', switch_tab) as tabs:
-        for name in tab_names:
-            ui.element('q-tab').props(f'name={name} label={name}')
+    with ui.tabs() as tabs:
+        ui.tab('A')
+        ui.tab('B')
+        ui.tab('C')
 
 
 with ui.footer(value=False) as footer:
 with ui.footer(value=False) as footer:
     ui.label('Footer')
     ui.label('Footer')
@@ -27,13 +15,14 @@ with ui.left_drawer().classes('bg-blue-100') as left_drawer:
     ui.label('Side menu')
     ui.label('Side menu')
 
 
 with ui.page_sticky(position='bottom-right', x_offset=20, y_offset=20):
 with ui.page_sticky(position='bottom-right', x_offset=20, y_offset=20):
-    ui.button(on_click=footer.toggle).props('fab icon=contact_support')
-
-
-# the page content consists of multiple tab panels
-with ui.element('q-tab-panels').props('model-value=A animated').classes('w-full') as panels:
-    for name in tab_names:
-        with ui.element('q-tab-panel').props(f'name={name}').classes('w-full'):
-            ui.label(f'Content of {name}')
+    ui.button(on_click=footer.toggle, icon='contact_support').props('fab')
+
+with ui.tab_panels(tabs, value='A').classes('w-full'):
+    with ui.tab_panel('A'):
+        ui.label('Content of A')
+    with ui.tab_panel('B'):
+        ui.label('Content of B')
+    with ui.tab_panel('C'):
+        ui.label('Content of C')
 
 
 ui.run()
 ui.run()

+ 20 - 0
examples/modularization/example_c.py

@@ -0,0 +1,20 @@
+import theme
+
+from nicegui import APIRouter, ui
+
+router = APIRouter(prefix='/c')
+
+
+@router.page('/')
+def example_page():
+    with theme.frame('- Example C -'):
+        ui.label('Example C').classes('text-h4 text-grey-8')
+        for i in range(1, 4):
+            ui.link(f'Item {i}', f'/c/items/{i}').classes('text-xl text-grey-8')
+
+
+@router.page('/items/{id}', dark=True)
+def item(id: str):
+    with theme.frame(f'- Example C{id} -'):
+        ui.label(f'Item  #{id}').classes('text-h4 text-grey-8')
+        ui.link('go back', router.prefix).classes('text-xl text-grey-8')

+ 2 - 7
examples/modularization/example_pages.py

@@ -6,16 +6,11 @@ from nicegui import ui
 def create() -> None:
 def create() -> None:
 
 
     @ui.page('/a')
     @ui.page('/a')
-    def example_page():
+    def example_page_a():
         with theme.frame('- Example A -'):
         with theme.frame('- Example A -'):
             ui.label('Example A').classes('text-h4 text-grey-8')
             ui.label('Example A').classes('text-h4 text-grey-8')
 
 
     @ui.page('/b')
     @ui.page('/b')
-    def example_page():
+    def example_page_b():
         with theme.frame('- Example B -'):
         with theme.frame('- Example B -'):
             ui.label('Example B').classes('text-h4 text-grey-8')
             ui.label('Example B').classes('text-h4 text-grey-8')
-
-    @ui.page('/c')
-    def example_page():
-        with theme.frame('- Example C -'):
-            ui.label('Example C').classes('text-h4 text-grey-8')

+ 5 - 1
examples/modularization/main.py

@@ -1,9 +1,10 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
+import example_c
 import example_pages
 import example_pages
 import home_page
 import home_page
 import theme
 import theme
 
 
-from nicegui import ui
+from nicegui import app, ui
 
 
 
 
 # here we use our custom page decorator directly and just put the content creation into a separate function
 # here we use our custom page decorator directly and just put the content creation into a separate function
@@ -16,4 +17,7 @@ def index_page() -> None:
 # this call shows that you can also move the whole page creation into a separate file
 # this call shows that you can also move the whole page creation into a separate file
 example_pages.create()
 example_pages.create()
 
 
+# we can also use the APIRouter as described in https://nicegui.io/documentation/page#modularize_with_apirouter
+app.include_router(example_c.router)
+
 ui.run(title='Modularization Example')
 ui.run(title='Modularization Example')

+ 1 - 1
examples/modularization/theme.py

@@ -14,5 +14,5 @@ def frame(navtitle: str):
         ui.label(navtitle)
         ui.label(navtitle)
         with ui.row():
         with ui.row():
             menu()
             menu()
-    with ui.row().classes('absolute-center'):
+    with ui.column().classes('absolute-center items-center'):
         yield
         yield

+ 3 - 3
examples/script_executor/main.py

@@ -4,11 +4,11 @@ import os.path
 import platform
 import platform
 import shlex
 import shlex
 
 
-from nicegui import background_tasks, ui
+from nicegui import ui
 
 
 
 
 async def run_command(command: str) -> None:
 async def run_command(command: str) -> None:
-    '''Run a command in the background and display the output in the pre-created dialog.'''
+    """Run a command in the background and display the output in the pre-created dialog."""
     dialog.open()
     dialog.open()
     result.content = ''
     result.content = ''
     process = await asyncio.create_subprocess_exec(
     process = await asyncio.create_subprocess_exec(
@@ -32,7 +32,7 @@ with ui.dialog() as dialog, ui.card():
 commands = ['python3 hello.py', 'python3 hello.py NiceGUI', 'python3 slow.py']
 commands = ['python3 hello.py', 'python3 hello.py NiceGUI', 'python3 slow.py']
 with ui.row():
 with ui.row():
     for command in commands:
     for command in commands:
-        ui.button(command, on_click=lambda _, c=command: background_tasks.create(run_command(c))).props('no-caps')
+        ui.button(command, on_click=lambda command=command: run_command(command)).props('no-caps')
 
 
 
 
 # NOTE on windows reload must be disabled to make asyncio.create_subprocess_exec work (see https://github.com/zauberzeug/nicegui/issues/486)
 # NOTE on windows reload must be disabled to make asyncio.create_subprocess_exec work (see https://github.com/zauberzeug/nicegui/issues/486)

+ 6 - 5
examples/single_page_app/main.py

@@ -4,21 +4,21 @@ from router import Router
 from nicegui import ui
 from nicegui import ui
 
 
 
 
-@ui.page('/')  # normal index page (eg. the entry point of the app)
+@ui.page('/')  # normal index page (e.g. the entry point of the app)
 @ui.page('/{_:path}')  # all other pages will be handled by the router but must be registered to also show the SPA index page
 @ui.page('/{_:path}')  # all other pages will be handled by the router but must be registered to also show the SPA index page
-async def main():
+def main():
     router = Router()
     router = Router()
 
 
     @router.add('/')
     @router.add('/')
-    async def show_one():
+    def show_one():
         ui.label('Content One').classes('text-2xl')
         ui.label('Content One').classes('text-2xl')
 
 
     @router.add('/two')
     @router.add('/two')
-    async def show_two():
+    def show_two():
         ui.label('Content Two').classes('text-2xl')
         ui.label('Content Two').classes('text-2xl')
 
 
     @router.add('/three')
     @router.add('/three')
-    async def show_three():
+    def show_three():
         ui.label('Content Three').classes('text-2xl')
         ui.label('Content Three').classes('text-2xl')
 
 
     # adding some navigation buttons to switch between the different pages
     # adding some navigation buttons to switch between the different pages
@@ -30,4 +30,5 @@ async def main():
     # this places the content which should be displayed
     # this places the content which should be displayed
     router.frame().classes('w-full p-4 bg-gray-100')
     router.frame().classes('w-full p-4 bg-gray-100')
 
 
+
 ui.run()
 ui.run()

+ 2 - 2
examples/sqlite_database/main.py

@@ -24,8 +24,8 @@ def users_ui() -> None:
                 ui.label(user['name'])
                 ui.label(user['name'])
                 ui.label(user['age'])
                 ui.label(user['age'])
             with ui.row():
             with ui.row():
-                ui.button('edit', on_click=lambda _, user=user: open_dialog(user))
-                ui.button('delete', on_click=lambda _, user=user: delete(user), color='red')
+                ui.button('edit', on_click=lambda user=user: open_dialog(user))
+                ui.button('delete', on_click=lambda user=user: delete(user), color='red')
 
 
 
 
 def create() -> None:
 def create() -> None:

+ 1 - 1
examples/table_and_slots/main.py

@@ -28,7 +28,7 @@ with ui.table(title='My Team', columns=columns, rows=rows, selection='multiple',
                     table.add_rows({'id': time.time(), 'name': new_name.value, 'age': new_age.value}),
                     table.add_rows({'id': time.time(), 'name': new_name.value, 'age': new_age.value}),
                     new_name.set_value(None),
                     new_name.set_value(None),
                     new_age.set_value(None),
                     new_age.set_value(None),
-                )).props('flat fab-mini icon=add')
+                ), icon='add').props('flat fab-mini')
             with table.cell():
             with table.cell():
                 new_name = ui.input('Name')
                 new_name = ui.input('Name')
             with table.cell():
             with table.cell():

+ 32 - 40
examples/todo_list/main.py

@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
-from dataclasses import dataclass
-from typing import List
+from dataclasses import dataclass, field
+from typing import Callable, List
 
 
 from nicegui import ui
 from nicegui import ui
 
 
@@ -11,55 +11,47 @@ class TodoItem:
     done: bool = False
     done: bool = False
 
 
 
 
-items: List[TodoItem] = [
-    TodoItem('Buy milk', done=True),
-    TodoItem('Clean the house'),
-    TodoItem('Call mom'),
-]
-
-
-def add(name: str) -> None:
-    items.append(TodoItem(name))
-    add_input.value = None
-    render_list.refresh()
-
-
-def remove(item: TodoItem) -> None:
-    items.remove(item)
-    render_list.refresh()
-
-
-def toggle(item: TodoItem) -> None:
-    item.done = not item.done
-    render_list.refresh()
+@dataclass
+class ToDoList:
+    title: str
+    on_change: Callable
+    items: List[TodoItem] = field(default_factory=list)
 
 
+    def add(self, name: str, done: bool = False) -> None:
+        self.items.append(TodoItem(name, done))
+        self.on_change()
 
 
-def rename(item: TodoItem, name: str) -> None:
-    item.name = name
-    render_list.refresh()
+    def remove(self, item: TodoItem) -> None:
+        self.items.remove(item)
+        self.on_change()
 
 
 
 
 @ui.refreshable
 @ui.refreshable
-def render_list():
-    if not items:
-        ui.label('List is empty.')
+def todo_ui():
+    if not todos.items:
+        ui.label('List is empty.').classes('mx-auto')
         return
         return
-    ui.linear_progress(sum(item.done for item in items) / len(items), show_value=False)
+    ui.linear_progress(sum(item.done for item in todos.items) / len(todos.items), show_value=False)
     with ui.row().classes('justify-center w-full'):
     with ui.row().classes('justify-center w-full'):
-        ui.label(f'Completed: {sum(item.done for item in items)}')
-        ui.label(f'Remaining: {sum(not item.done for item in items)}')
-    for item in items:
+        ui.label(f'Completed: {sum(item.done for item in todos.items)}')
+        ui.label(f'Remaining: {sum(not item.done for item in todos.items)}')
+    for item in todos.items:
         with ui.row().classes('items-center'):
         with ui.row().classes('items-center'):
-            ui.checkbox(value=item.done, on_change=lambda _, item=item: toggle(item))
-            input = ui.input(value=item.name).classes('flex-grow')
-            input.on('keydown.enter', lambda _, item=item, input=input: rename(item, input.value))
-            ui.button(on_click=lambda _, item=item: remove(item)).props('flat fab-mini icon=delete color=grey')
+            ui.checkbox(value=item.done, on_change=todo_ui.refresh).bind_value(item, 'done')
+            ui.input(value=item.name).classes('flex-grow').bind_value(item, 'name')
+            ui.button(on_click=lambda item=item: todos.remove(item), icon='delete').props('flat fab-mini color=grey')
+
 
 
+todos = ToDoList('My Weekend', on_change=todo_ui.refresh)
+todos.add('Order pizza', done=True)
+todos.add('New NiceGUI Release')
+todos.add('Clean the house')
+todos.add('Call mom')
 
 
 with ui.card().classes('w-80 items-stretch'):
 with ui.card().classes('w-80 items-stretch'):
-    ui.label('Todo list').classes('text-semibold text-2xl')
-    render_list()
+    ui.label().bind_text_from(todos, 'title').classes('text-semibold text-2xl')
+    todo_ui()
     add_input = ui.input('New item').classes('mx-12')
     add_input = ui.input('New item').classes('mx-12')
-    add_input.on('keydown.enter', lambda: add(add_input.value))
+    add_input.on('keydown.enter', lambda: (todos.add(add_input.value), add_input.set_value('')))
 
 
 ui.run()
 ui.run()

+ 5 - 3
fetch_tailwind.py

@@ -94,7 +94,7 @@ for property in properties:
 with open(Path(__file__).parent / 'nicegui' / 'tailwind.py', 'w') as f:
 with open(Path(__file__).parent / 'nicegui' / 'tailwind.py', 'w') as f:
     f.write('from __future__ import annotations\n')
     f.write('from __future__ import annotations\n')
     f.write('\n')
     f.write('\n')
-    f.write('from typing import TYPE_CHECKING, List, Optional, overload\n')
+    f.write('from typing import TYPE_CHECKING, List, Optional, Union, overload\n')
     f.write('\n')
     f.write('\n')
     f.write('if TYPE_CHECKING:\n')
     f.write('if TYPE_CHECKING:\n')
     f.write('    from .element import Element\n')
     f.write('    from .element import Element\n')
@@ -116,10 +116,10 @@ with open(Path(__file__).parent / 'nicegui' / 'tailwind.py', 'w') as f:
     f.write('class Tailwind:\n')
     f.write('class Tailwind:\n')
     f.write('\n')
     f.write('\n')
     f.write("    def __init__(self, _element: Optional['Element'] = None) -> None:\n")
     f.write("    def __init__(self, _element: Optional['Element'] = None) -> None:\n")
-    f.write('        self.element = _element or PseudoElement()\n')
+    f.write('        self.element: Union[PseudoElement, Element] = PseudoElement() if _element is None else _element\n')
     f.write('\n')
     f.write('\n')
     f.write('    @overload\n')
     f.write('    @overload\n')
-    f.write('    def __call__(self, Tailwind) -> Tailwind:\n')
+    f.write('    def __call__(self, tailwind: Tailwind) -> Tailwind:\n')
     f.write('        ...\n')
     f.write('        ...\n')
     f.write('\n')
     f.write('\n')
     f.write('    @overload\n')
     f.write('    @overload\n')
@@ -127,6 +127,8 @@ with open(Path(__file__).parent / 'nicegui' / 'tailwind.py', 'w') as f:
     f.write('        ...\n')
     f.write('        ...\n')
     f.write('\n')
     f.write('\n')
     f.write('    def __call__(self, *args) -> Tailwind:\n')
     f.write('    def __call__(self, *args) -> Tailwind:\n')
+    f.write('        if not args:\n')
+    f.write('           return self\n')
     f.write('        if isinstance(args[0], Tailwind):\n')
     f.write('        if isinstance(args[0], Tailwind):\n')
     f.write('            args[0].apply(self.element)\n')
     f.write('            args[0].apply(self.element)\n')
     f.write('        else:\n')
     f.write('        else:\n')

+ 1 - 1
fly.toml

@@ -26,7 +26,7 @@ strategy = "rolling"
   script_checks = []
   script_checks = []
   [services.concurrency]
   [services.concurrency]
     hard_limit = 30
     hard_limit = 30
-    soft_limit = 15
+    soft_limit = 12
     type = "connections"
     type = "connections"
 
 
   [[services.ports]]
   [[services.ports]]

+ 17 - 12
main.py

@@ -23,6 +23,7 @@ from nicegui import ui
 from website import documentation, example_card, svg
 from website import documentation, example_card, svg
 from website.demo import bash_window, browser_window, python_window
 from website.demo import bash_window, browser_window, python_window
 from website.documentation_tools import create_anchor_name, element_demo, generate_class_doc
 from website.documentation_tools import create_anchor_name, element_demo, generate_class_doc
+from website.search import Search
 from website.star import add_star
 from website.star import add_star
 from website.style import example_link, features, heading, link_target, section_heading, side_menu, subtitle, title
 from website.style import example_link, features, heading, link_target, section_heading, side_menu, subtitle, title
 
 
@@ -33,6 +34,7 @@ app.add_middleware(SessionMiddleware, secret_key=os.environ.get('NICEGUI_SECRET_
 
 
 app.add_static_files('/favicon', str(Path(__file__).parent / 'website' / 'favicon'))
 app.add_static_files('/favicon', str(Path(__file__).parent / 'website' / 'favicon'))
 app.add_static_files('/fonts', str(Path(__file__).parent / 'website' / 'fonts'))
 app.add_static_files('/fonts', str(Path(__file__).parent / 'website' / 'fonts'))
+app.add_static_files('/static', str(Path(__file__).parent / 'website' / 'static'))
 
 
 
 
 @app.get('/logo.png')
 @app.get('/logo.png')
@@ -76,25 +78,27 @@ def add_header(menu: Optional[ui.left_drawer] = None) -> None:
             .classes('items-center duration-200 p-0 px-4 no-wrap') \
             .classes('items-center duration-200 p-0 px-4 no-wrap') \
             .style('box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1)'):
             .style('box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1)'):
         if menu:
         if menu:
-            ui.button(on_click=menu.toggle).props('flat color=white icon=menu round').classes('lg:hidden')
+            ui.button(on_click=menu.toggle, icon='menu').props('flat color=white round').classes('lg:hidden')
         with ui.link(target=index_page).classes('row gap-4 items-center no-wrap mr-auto'):
         with ui.link(target=index_page).classes('row gap-4 items-center no-wrap mr-auto'):
-            svg.face().classes('w-8 stroke-white stroke-2')
+            svg.face().classes('w-8 stroke-white stroke-2 max-[550px]:hidden')
             svg.word().classes('w-24')
             svg.word().classes('w-24')
         with ui.row().classes('max-lg:hidden'):
         with ui.row().classes('max-lg:hidden'):
             for title, target in menu_items.items():
             for title, target in menu_items.items():
                 ui.link(title, target).classes(replace='text-lg text-white')
                 ui.link(title, target).classes(replace='text-lg text-white')
-        with ui.link(target='https://discord.gg/TEpFeAaF4f').classes('max-[435px]:hidden').tooltip('Discord'):
+        search = Search()
+        search.create_button()
+        with ui.link(target='https://discord.gg/TEpFeAaF4f').classes('max-[445px]:hidden').tooltip('Discord'):
             svg.discord().classes('fill-white scale-125 m-1')
             svg.discord().classes('fill-white scale-125 m-1')
-        with ui.link(target='https://www.reddit.com/r/nicegui/').classes('max-[385px]:hidden').tooltip('Reddit'):
+        with ui.link(target='https://www.reddit.com/r/nicegui/').classes('max-[395px]:hidden').tooltip('Reddit'):
             svg.reddit().classes('fill-white scale-125 m-1')
             svg.reddit().classes('fill-white scale-125 m-1')
         with ui.link(target='https://github.com/zauberzeug/nicegui/').tooltip('GitHub'):
         with ui.link(target='https://github.com/zauberzeug/nicegui/').tooltip('GitHub'):
             svg.github().classes('fill-white scale-125 m-1')
             svg.github().classes('fill-white scale-125 m-1')
-        add_star().classes('max-[480px]:hidden')
+        add_star().classes('max-[490px]:hidden')
         with ui.row().classes('lg:hidden'):
         with ui.row().classes('lg:hidden'):
-            with ui.button().props('flat color=white icon=more_vert round'):
+            with ui.button(icon='more_vert').props('flat color=white round'):
                 with ui.menu().classes('bg-primary text-white text-lg').props(remove='no-parent-event'):
                 with ui.menu().classes('bg-primary text-white text-lg').props(remove='no-parent-event'):
                     for title, target in menu_items.items():
                     for title, target in menu_items.items():
-                        ui.menu_item(title, on_click=lambda _, target=target: ui.open(target))
+                        ui.menu_item(title, on_click=lambda target=target: ui.open(target))
 
 
 
 
 @ui.page('/')
 @ui.page('/')
@@ -205,7 +209,7 @@ async def index_page(client: Client) -> None:
             features('insights', 'Visualization', [
             features('insights', 'Visualization', [
                 'charts, diagrams and tables',
                 'charts, diagrams and tables',
                 '3D scenes',
                 '3D scenes',
-                'progress bars',
+                'straight-forward data binding',
                 'built-in timer for data refresh',
                 'built-in timer for data refresh',
             ])
             ])
             features('brush', 'Styling', [
             features('brush', 'Styling', [
@@ -217,7 +221,7 @@ async def index_page(client: Client) -> None:
             features('source', 'Coding', [
             features('source', 'Coding', [
                 'routing for multiple pages',
                 'routing for multiple pages',
                 'auto-reload on code change',
                 'auto-reload on code change',
-                'straight-forward data binding',
+                'persistent user sessions',
                 'Jupyter notebook compatibility',
                 'Jupyter notebook compatibility',
             ])
             ])
             features('anchor', 'Foundation', [
             features('anchor', 'Foundation', [
@@ -279,6 +283,7 @@ async def index_page(client: Client) -> None:
             example_link('Chat with AI', 'a simple chat app with AI')
             example_link('Chat with AI', 'a simple chat app with AI')
             example_link('SQLite Database', 'CRUD operations on a SQLite database')
             example_link('SQLite Database', 'CRUD operations on a SQLite database')
             example_link('Pandas DataFrame', 'displays an editable [pandas](https://pandas.pydata.org) DataFrame')
             example_link('Pandas DataFrame', 'displays an editable [pandas](https://pandas.pydata.org) DataFrame')
+            example_link('Lightbox', 'A thumbnail gallery where each image can be clicked to enlarge')
 
 
     with ui.row().classes('bg-primary w-full min-h-screen mt-16'):
     with ui.row().classes('bg-primary w-full min-h-screen mt-16'):
         link_target('why')
         link_target('why')
@@ -335,8 +340,8 @@ def documentation_page() -> None:
 
 
 @ui.page('/documentation/{name}')
 @ui.page('/documentation/{name}')
 async def documentation_page_more(name: str, client: Client) -> None:
 async def documentation_page_more(name: str, client: Client) -> None:
-    if not hasattr(ui, name):
-        name = name.replace('_', '')  # NOTE: "AG Grid" leads to anchor name "ag_grid", but class is `ui.aggrid`
+    if name == 'ag_grid':
+        name = 'aggrid'  # NOTE: "AG Grid" leads to anchor name "ag_grid", but class is `ui.aggrid`
     module = importlib.import_module(f'website.more_documentation.{name}_documentation')
     module = importlib.import_module(f'website.more_documentation.{name}_documentation')
     more = getattr(module, 'more', None)
     more = getattr(module, 'more', None)
     if hasattr(ui, name):
     if hasattr(ui, name):
@@ -351,7 +356,7 @@ async def documentation_page_more(name: str, client: Client) -> None:
     with side_menu() as menu:
     with side_menu() as menu:
         ui.markdown(f'[← back](/documentation#{create_anchor_name(back_link_target)})').classes('bold-links')
         ui.markdown(f'[← back](/documentation#{create_anchor_name(back_link_target)})').classes('bold-links')
     with ui.column().classes('w-full p-8 lg:p-16 max-w-[1250px] mx-auto'):
     with ui.column().classes('w-full p-8 lg:p-16 max-w-[1250px] mx-auto'):
-        section_heading('Documentation', f'ui.*{name}*')
+        section_heading('Documentation', f'ui.*{name}*' if hasattr(ui, name) else f'*{name.replace("_", " ").title()}*')
         with menu:
         with menu:
             ui.markdown('**Demos**' if more else '**Demo**').classes('mt-4')
             ui.markdown('**Demos**' if more else '**Demo**').classes('mt-4')
         element_demo(api)(getattr(module, 'main_demo'))
         element_demo(api)(getattr(module, 'main_demo'))

+ 2 - 0
mypy.ini

@@ -0,0 +1,2 @@
+[mypy]
+ignore_missing_imports = True

+ 2 - 1
nicegui/__init__.py

@@ -3,9 +3,10 @@ try:
 except ModuleNotFoundError:
 except ModuleNotFoundError:
     import importlib_metadata
     import importlib_metadata
 
 
-__version__ = importlib_metadata.version('nicegui')
+__version__: str = importlib_metadata.version('nicegui')
 
 
 from . import elements, globals, ui
 from . import elements, globals, ui
+from .api_router import APIRouter
 from .client import Client
 from .client import Client
 from .nicegui import app
 from .nicegui import app
 from .tailwind import Tailwind
 from .tailwind import Tailwind

+ 44 - 0
nicegui/api_router.py

@@ -0,0 +1,44 @@
+from pathlib import Path
+from typing import Callable, Optional, Union
+
+import fastapi
+
+from .page import page as ui_page
+
+
+class APIRouter(fastapi.APIRouter):
+
+    def page(self,
+             path: str, *,
+             title: Optional[str] = None,
+             viewport: Optional[str] = None,
+             favicon: Optional[Union[str, Path]] = None,
+             dark: Optional[bool] = ...,
+             response_timeout: float = 3.0,
+             **kwargs,
+             ) -> Callable:
+        """Page
+
+        Creates a new page at the given route.
+        Each user will see a new instance of the page.
+        This means it is private to the user and not shared with others
+        (as it is done `when placing elements outside of a page decorator <https://nicegui.io/documentation#auto-index_page>`_).
+
+        :param path: route of the new page (path must start with '/')
+        :param title: optional page title
+        :param viewport: optional viewport meta tag content
+        :param favicon: optional relative filepath or absolute URL to a favicon (default: `None`, NiceGUI icon will be used)
+        :param dark: whether to use Quasar's dark mode (defaults to `dark` argument of `run` command)
+        :param response_timeout: maximum time for the decorated function to build the page (default: 3.0)
+        :param kwargs: additional keyword arguments passed to FastAPI's @app.get method
+        """
+        return ui_page(
+            path,
+            title=title,
+            viewport=viewport,
+            favicon=favicon,
+            dark=dark,
+            response_timeout=response_timeout,
+            api_router=self,
+            **kwargs
+        )

+ 90 - 7
nicegui/app.py

@@ -1,10 +1,18 @@
-from typing import Awaitable, Callable, Union
+import hashlib
+from pathlib import Path
+from typing import Awaitable, Callable, Optional, Union
 
 
-from fastapi import FastAPI
+from fastapi import FastAPI, Request
+from fastapi.responses import FileResponse, StreamingResponse
 from fastapi.staticfiles import StaticFiles
 from fastapi.staticfiles import StaticFiles
 
 
-from . import globals
+from . import globals, helpers
 from .native import Native
 from .native import Native
+from .storage import Storage
+
+
+def hash_file_path(path: Path) -> str:
+    return hashlib.sha256(str(path.resolve()).encode()).hexdigest()[:32]
 
 
 
 
 class App(FastAPI):
 class App(FastAPI):
@@ -12,6 +20,7 @@ class App(FastAPI):
     def __init__(self, **kwargs) -> None:
     def __init__(self, **kwargs) -> None:
         super().__init__(**kwargs)
         super().__init__(**kwargs)
         self.native = Native()
         self.native = Native()
+        self.storage = Storage()
 
 
     def on_connect(self, handler: Union[Callable, Awaitable]) -> None:
     def on_connect(self, handler: Union[Callable, Awaitable]) -> None:
         """Called every time a new client connects to NiceGUI.
         """Called every time a new client connects to NiceGUI.
@@ -43,7 +52,7 @@ class App(FastAPI):
         """
         """
         globals.shutdown_handlers.append(handler)
         globals.shutdown_handlers.append(handler)
 
 
-    def on_exception(self, handler: Union[Callable, Awaitable]) -> None:
+    def on_exception(self, handler: Callable) -> None:
         """Called when an exception occurs.
         """Called when an exception occurs.
 
 
         The callback has an optional parameter of `Exception`.
         The callback has an optional parameter of `Exception`.
@@ -60,20 +69,94 @@ class App(FastAPI):
             raise Exception('calling shutdown() is not supported when auto-reload is enabled')
             raise Exception('calling shutdown() is not supported when auto-reload is enabled')
         globals.server.should_exit = True
         globals.server.should_exit = True
 
 
-    def add_static_files(self, url_path: str, local_directory: str) -> None:
-        """Add static files.
+    def add_static_files(self, url_path: str, local_directory: Union[str, Path]) -> None:
+        """Add a directory of static files.
 
 
         `add_static_files()` makes a local directory available at the specified endpoint, e.g. `'/static'`.
         `add_static_files()` makes a local directory available at the specified endpoint, e.g. `'/static'`.
         This is useful for providing local data like images to the frontend.
         This is useful for providing local data like images to the frontend.
         Otherwise the browser would not be able to access the files.
         Otherwise the browser would not be able to access the files.
         Do only put non-security-critical files in there, as they are accessible to everyone.
         Do only put non-security-critical files in there, as they are accessible to everyone.
 
 
+        To make a single file accessible, you can use `add_static_file()`.
+        For media files which should be streamed, you can use `add_media_files()` or `add_media_file()` instead.
+
         :param url_path: string that starts with a slash "/" and identifies the path at which the files should be served
         :param url_path: string that starts with a slash "/" and identifies the path at which the files should be served
         :param local_directory: local folder with files to serve as static content
         :param local_directory: local folder with files to serve as static content
         """
         """
         if url_path == '/':
         if url_path == '/':
             raise ValueError('''Path cannot be "/", because it would hide NiceGUI's internal "/_nicegui" route.''')
             raise ValueError('''Path cannot be "/", because it would hide NiceGUI's internal "/_nicegui" route.''')
-        globals.app.mount(url_path, StaticFiles(directory=local_directory))
+        globals.app.mount(url_path, StaticFiles(directory=str(local_directory)))
+
+    def add_static_file(self, *, local_file: Union[str, Path], url_path: Optional[str] = None) -> str:
+        """Add a single static file.
+
+        Allows a local file to be accessed online with enabled caching.
+        If `url_path` is not specified, a path will be generated.
+
+        To make a whole folder of files accessible, use `add_static_files()` instead.
+        For media files which should be streamed, you can use `add_media_files()` or `add_media_file()` instead.
+
+        :param local_file: local file to serve as static content
+        :param url_path: string that starts with a slash "/" and identifies the path at which the file should be served (default: None -> auto-generated URL path)
+        :return: URL path which can be used to access the file
+        """
+        file = Path(local_file)
+        if not file.is_file():
+            raise ValueError(f'File not found: {file}')
+        if url_path is None:
+            url_path = f'/_nicegui/auto/static/{hash_file_path(file)}/{file.name}'
+
+        @self.get(url_path)
+        async def read_item() -> FileResponse:
+            return FileResponse(file, headers={'Cache-Control': 'public, max-age=3600'})
+
+        return url_path
+
+    def add_media_files(self, url_path: str, local_directory: Union[str, Path]) -> None:
+        """Add directory of media files.
+
+        `add_media_files()` allows a local files to be streamed from a specified endpoint, e.g. `'/media'`.
+        This should be used for media files to support proper streaming.
+        Otherwise the browser would not be able to access and load the the files incrementally or jump to different positions in the stream.
+        Do only put non-security-critical files in there, as they are accessible to everyone.
+
+        To make a single file accessible via streaming, you can use `add_media_file()`.
+        For small static files, you can use `add_static_files()` or `add_static_file()` instead.
+
+        :param url_path: string that starts with a slash "/" and identifies the path at which the files should be served
+        :param local_directory: local folder with files to serve as media content
+        """
+        @self.get(url_path + '/{filename}')
+        async def read_item(request: Request, filename: str) -> StreamingResponse:
+            filepath = Path(local_directory) / filename
+            if not filepath.is_file():
+                return {'detail': 'Not Found'}, 404
+            return helpers.get_streaming_response(filepath, request)
+
+    def add_media_file(self, *, local_file: Union[str, Path], url_path: Optional[str] = None) -> None:
+        """Add a single media file.
+
+        Allows a local file to be streamed.
+        If `url_path` is not specified, a path will be generated.
+
+        To make a whole folder of media files accessible via streaming, use `add_media_files()` instead.
+        For small static files, you can use `add_static_files()` or `add_static_file()` instead.
+
+        :param local_file: local file to serve as media content
+        :param url_path: string that starts with a slash "/" and identifies the path at which the file should be served (default: None -> auto-generated URL path)
+        :return: URL path which can be used to access the file
+        """
+        file = Path(local_file)
+        if not file.is_file():
+            raise ValueError(f'File not found: {local_file}')
+        if url_path is None:
+            url_path = f'/_nicegui/auto/media/{hash_file_path(file)}/{file.name}'
+
+        @self.get(url_path)
+        async def read_item(request: Request) -> StreamingResponse:
+            return helpers.get_streaming_response(file, request)
+
+        return url_path
 
 
     def remove_route(self, path: str) -> None:
     def remove_route(self, path: str) -> None:
         """Remove routes with the given path."""
         """Remove routes with the given path."""

+ 3 - 1
nicegui/background_tasks.py

@@ -21,7 +21,9 @@ def create(coroutine: Awaitable[T], *, name: str = 'unnamed task') -> 'asyncio.T
     Also a reference to the task is kept until it is done, so that the task is not garbage collected mid-execution.
     Also a reference to the task is kept until it is done, so that the task is not garbage collected mid-execution.
     See https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task.
     See https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task.
     """
     """
-    task = globals.loop.create_task(coroutine, name=name) if name_supported else globals.loop.create_task(coroutine)
+    assert globals.loop is not None
+    task: asyncio.Task = \
+        globals.loop.create_task(coroutine, name=name) if name_supported else globals.loop.create_task(coroutine)
     task.add_done_callback(_handle_task_result)
     task.add_done_callback(_handle_task_result)
     running_tasks.add(task)
     running_tasks.add(task)
     task.add_done_callback(running_tasks.discard)
     task.add_done_callback(running_tasks.discard)

+ 22 - 12
nicegui/binding.py

@@ -2,6 +2,7 @@ import asyncio
 import logging
 import logging
 import time
 import time
 from collections import defaultdict
 from collections import defaultdict
+from collections.abc import Mapping
 from typing import Any, Callable, DefaultDict, Dict, List, Optional, Set, Tuple, Type, Union
 from typing import Any, Callable, DefaultDict, Dict, List, Optional, Set, Tuple, Type, Union
 
 
 from . import globals
 from . import globals
@@ -11,30 +12,38 @@ bindable_properties: Dict[Tuple[int, str], Any] = {}
 active_links: List[Tuple[Any, str, Any, str, Callable[[Any], Any]]] = []
 active_links: List[Tuple[Any, str, Any, str, Callable[[Any], Any]]] = []
 
 
 
 
-def get_attribute(obj: Union[object, Dict], name: str) -> Any:
-    if isinstance(obj, dict):
+def has_attribute(obj: Union[object, Mapping], name: str) -> Any:
+    if isinstance(obj, Mapping):
+        return name in obj
+    else:
+        return hasattr(obj, name)
+
+
+def get_attribute(obj: Union[object, Mapping], name: str) -> Any:
+    if isinstance(obj, Mapping):
         return obj[name]
         return obj[name]
     else:
     else:
         return getattr(obj, name)
         return getattr(obj, name)
 
 
 
 
-def set_attribute(obj: Union[object, Dict], name: str, value: Any) -> None:
+def set_attribute(obj: Union[object, Mapping], name: str, value: Any) -> None:
     if isinstance(obj, dict):
     if isinstance(obj, dict):
         obj[name] = value
         obj[name] = value
     else:
     else:
         setattr(obj, name, value)
         setattr(obj, name, value)
 
 
 
 
-async def loop():
+async def loop() -> None:
     while True:
     while True:
         visited: Set[Tuple[int, str]] = set()
         visited: Set[Tuple[int, str]] = set()
         t = time.time()
         t = time.time()
         for link in active_links:
         for link in active_links:
             (source_obj, source_name, target_obj, target_name, transform) = link
             (source_obj, source_name, target_obj, target_name, transform) = link
-            value = transform(get_attribute(source_obj, source_name))
-            if get_attribute(target_obj, target_name) != value:
-                set_attribute(target_obj, target_name, value)
-                propagate(target_obj, target_name, visited)
+            if has_attribute(source_obj, source_name):
+                value = transform(get_attribute(source_obj, source_name))
+                if not has_attribute(target_obj, target_name) or get_attribute(target_obj, target_name) != value:
+                    set_attribute(target_obj, target_name, value)
+                    propagate(target_obj, target_name, visited)
             del link, source_obj, target_obj
             del link, source_obj, target_obj
         if time.time() - t > 0.01:
         if time.time() - t > 0.01:
             logging.warning(f'binding propagation for {len(active_links)} active links took {time.time() - t:.3f} s')
             logging.warning(f'binding propagation for {len(active_links)} active links took {time.time() - t:.3f} s')
@@ -48,10 +57,11 @@ def propagate(source_obj: Any, source_name: str, visited: Optional[Set[Tuple[int
     for _, target_obj, target_name, transform in bindings.get((id(source_obj), source_name), []):
     for _, target_obj, target_name, transform in bindings.get((id(source_obj), source_name), []):
         if (id(target_obj), target_name) in visited:
         if (id(target_obj), target_name) in visited:
             continue
             continue
-        target_value = transform(get_attribute(source_obj, source_name))
-        if get_attribute(target_obj, target_name) != target_value:
-            set_attribute(target_obj, target_name, target_value)
-            propagate(target_obj, target_name, visited)
+        if has_attribute(source_obj, source_name):
+            target_value = transform(get_attribute(source_obj, source_name))
+            if not has_attribute(target_obj, target_name) or get_attribute(target_obj, target_name) != target_value:
+                set_attribute(target_obj, target_name, target_value)
+                propagate(target_obj, target_name, visited)
 
 
 
 
 def bind_to(self_obj: Any, self_name: str, other_obj: Any, other_name: str, forward: Callable[[Any], Any]) -> None:
 def bind_to(self_obj: Any, self_name: str, other_obj: Any, other_name: str, forward: Callable[[Any], Any]) -> None:

+ 2 - 2
nicegui/client.py

@@ -40,7 +40,7 @@ class Client:
                 with Element('q-page'):
                 with Element('q-page'):
                     self.content = Element('div').classes('nicegui-content')
                     self.content = Element('div').classes('nicegui-content')
 
 
-        self.waiting_javascript_commands: Dict[str, str] = {}
+        self.waiting_javascript_commands: Dict[str, Any] = {}
 
 
         self.head_html = ''
         self.head_html = ''
         self.body_html = ''
         self.body_html = ''
@@ -108,7 +108,7 @@ class Client:
         self.is_waiting_for_disconnect = False
         self.is_waiting_for_disconnect = False
 
 
     async def run_javascript(self, code: str, *,
     async def run_javascript(self, code: str, *,
-                             respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Optional[str]:
+                             respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Optional[Any]:
         """Execute JavaScript on the client.
         """Execute JavaScript on the client.
 
 
         The client connection must be established before this method is called.
         The client connection must be established before this method is called.

+ 0 - 33
nicegui/colors.py

@@ -1,33 +0,0 @@
-from typing import Optional
-
-from typing_extensions import get_args
-
-from .element import Element
-from .tailwind_types.background_color import BackgroundColor
-
-QUASAR_COLORS = {'primary', 'secondary', 'accent', 'dark', 'positive', 'negative', 'info', 'warning'}
-for color in {'red', 'pink', 'purple', 'deep-purple', 'indigo', 'blue', 'light-blue', 'cyan', 'teal', 'green',
-              'light-green', 'lime', 'yellow', 'amber', 'orange', 'deep-orange', 'brown', 'grey', 'blue-grey'}:
-    QUASAR_COLORS.add(color)
-    for i in range(1, 15):
-        QUASAR_COLORS.add(f'{color}-{i}')
-
-TAILWIND_COLORS = get_args(BackgroundColor)
-
-
-def set_text_color(element: Element, color: Optional[str], *, prop_name: str = 'color') -> None:
-    if color in QUASAR_COLORS:
-        element._props[prop_name] = color
-    elif color in TAILWIND_COLORS:
-        element._classes.append(f'text-{color}')
-    elif color is not None:
-        element._style['color'] = color
-
-
-def set_background_color(element: Element, color: Optional[str], *, prop_name: str = 'color') -> None:
-    if color in QUASAR_COLORS:
-        element._props[prop_name] = color
-    elif color in TAILWIND_COLORS:
-        element._classes.append(f'bg-{color}')
-    elif color is not None:
-        element._style['background-color'] = color

+ 5 - 2
nicegui/element.py

@@ -9,7 +9,7 @@ from typing_extensions import Self
 
 
 from nicegui import json
 from nicegui import json
 
 
-from . import binding, events, globals, outbox
+from . import binding, events, globals, outbox, storage
 from .elements.mixins.visibility import Visibility
 from .elements.mixins.visibility import Visibility
 from .event_listener import EventListener
 from .event_listener import EventListener
 from .slot import Slot
 from .slot import Slot
@@ -80,7 +80,7 @@ class Element(Visibility):
             for child in slot:
             for child in slot:
                 yield child
                 yield child
 
 
-    def _collect_slot_dict(self) -> Dict[str, List[int]]:
+    def _collect_slot_dict(self) -> Dict[str, Any]:
         return {
         return {
             name: {'template': slot.template, 'ids': [child.id for child in slot]}
             name: {'template': slot.template, 'ids': [child.id for child in slot]}
             for name, slot in self.slots.items()
             for name, slot in self.slots.items()
@@ -230,6 +230,7 @@ class Element(Visibility):
                 throttle=throttle,
                 throttle=throttle,
                 leading_events=leading_events,
                 leading_events=leading_events,
                 trailing_events=trailing_events,
                 trailing_events=trailing_events,
+                request=storage.request_contextvar.get(),
             )
             )
             self._event_listeners[listener.id] = listener
             self._event_listeners[listener.id] = listener
             self.update()
             self.update()
@@ -237,6 +238,7 @@ class Element(Visibility):
 
 
     def _handle_event(self, msg: Dict) -> None:
     def _handle_event(self, msg: Dict) -> None:
         listener = self._event_listeners[msg['listener_id']]
         listener = self._event_listeners[msg['listener_id']]
+        storage.request_contextvar.set(listener.request)
         events.handle_event(listener.handler, msg, sender=self)
         events.handle_event(listener.handler, msg, sender=self)
 
 
     def update(self) -> None:
     def update(self) -> None:
@@ -276,6 +278,7 @@ class Element(Visibility):
         :param target_container: container to move the element to (default: the parent container)
         :param target_container: container to move the element to (default: the parent container)
         :param target_index: index within the target slot (default: append to the end)
         :param target_index: index within the target slot (default: append to the end)
         """
         """
+        assert self.parent_slot is not None
         self.parent_slot.children.remove(self)
         self.parent_slot.children.remove(self)
         self.parent_slot.parent.update()
         self.parent_slot.parent.update()
         target_container = target_container or self.parent_slot.parent
         target_container = target_container or self.parent_slot.parent

+ 3 - 2
nicegui/elements/aggrid.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
-from typing import Dict, List, Optional
+from typing import Dict, List, Optional, cast
 
 
 from ..dependencies import register_component
 from ..dependencies import register_component
 from ..element import Element
 from ..element import Element
@@ -67,7 +67,8 @@ class AgGrid(Element):
 
 
         :return: list of selected row data
         :return: list of selected row data
         """
         """
-        return await run_javascript(f'return getElement({self.id}).gridOptions.api.getSelectedRows();')
+        result = await run_javascript(f'return getElement({self.id}).gridOptions.api.getSelectedRows();')
+        return cast(List[Dict], result)
 
 
     async def get_selected_row(self) -> Optional[Dict]:
     async def get_selected_row(self) -> Optional[Dict]:
         """Get the single currently selected row.
         """Get the single currently selected row.

+ 7 - 2
nicegui/elements/audio.py

@@ -1,5 +1,8 @@
 import warnings
 import warnings
+from pathlib import Path
+from typing import Union
 
 
+from .. import globals
 from ..dependencies import register_component
 from ..dependencies import register_component
 from ..element import Element
 from ..element import Element
 
 
@@ -8,7 +11,7 @@ register_component('audio', __file__, 'audio.js')
 
 
 class Audio(Element):
 class Audio(Element):
 
 
-    def __init__(self, src: str, *,
+    def __init__(self, src: Union[str, Path], *,
                  controls: bool = True,
                  controls: bool = True,
                  autoplay: bool = False,
                  autoplay: bool = False,
                  muted: bool = False,
                  muted: bool = False,
@@ -17,7 +20,7 @@ class Audio(Element):
                  ) -> None:
                  ) -> None:
         """Audio
         """Audio
 
 
-        :param src: URL of the audio source
+        :param src: URL or local file path of the audio source
         :param controls: whether to show the audio controls, like play, pause, and volume (default: `True`)
         :param controls: whether to show the audio controls, like play, pause, and volume (default: `True`)
         :param autoplay: whether to start playing the audio automatically (default: `False`)
         :param autoplay: whether to start playing the audio automatically (default: `False`)
         :param muted: whether the audio should be initially muted (default: `False`)
         :param muted: whether the audio should be initially muted (default: `False`)
@@ -27,6 +30,8 @@ class Audio(Element):
         for a list of events you can subscribe to using the generic event subscription `on()`.
         for a list of events you can subscribe to using the generic event subscription `on()`.
         """
         """
         super().__init__('audio')
         super().__init__('audio')
+        if Path(src).is_file():
+            src = globals.app.add_media_file(local_file=src)
         self._props['src'] = src
         self._props['src'] = src
         self._props['controls'] = controls
         self._props['controls'] = controls
         self._props['autoplay'] = autoplay
         self._props['autoplay'] = autoplay

+ 4 - 7
nicegui/elements/avatar.py

@@ -1,10 +1,10 @@
 from typing import Optional
 from typing import Optional
 
 
-from ..colors import set_background_color, set_text_color
-from ..element import Element
+from .mixins.color_elements import BackgroundColorElement, TextColorElement
 
 
 
 
-class Avatar(Element):
+class Avatar(BackgroundColorElement, TextColorElement):
+    TEXT_COLOR_PROP = 'text-color'
 
 
     def __init__(self,
     def __init__(self,
                  icon: Optional[str] = None, *,
                  icon: Optional[str] = None, *,
@@ -28,16 +28,13 @@ class Avatar(Element):
         :param square: removes border-radius so borders are squared (default: False)
         :param square: removes border-radius so borders are squared (default: False)
         :param rounded: applies a small standard border-radius for a squared shape of the component (default: False)
         :param rounded: applies a small standard border-radius for a squared shape of the component (default: False)
         """
         """
-        super().__init__('q-avatar')
+        super().__init__(tag='q-avatar', background_color=color, text_color=text_color)
 
 
         if icon is not None:
         if icon is not None:
             self._props['icon'] = icon
             self._props['icon'] = icon
         self._props['square'] = square
         self._props['square'] = square
         self._props['rounded'] = rounded
         self._props['rounded'] = rounded
 
 
-        set_background_color(self, color)
-        set_text_color(self, text_color, prop_name='text-color')
-
         if size is not None:
         if size is not None:
             self._props['size'] = size
             self._props['size'] = size
 
 

+ 4 - 5
nicegui/elements/badge.py

@@ -1,10 +1,11 @@
 from typing import Optional
 from typing import Optional
 
 
-from ..colors import set_background_color, set_text_color
+from .mixins.color_elements import BackgroundColorElement, TextColorElement
 from .mixins.text_element import TextElement
 from .mixins.text_element import TextElement
 
 
 
 
-class Badge(TextElement):
+class Badge(TextElement, BackgroundColorElement, TextColorElement):
+    TEXT_COLOR_PROP = 'text-color'
 
 
     def __init__(self,
     def __init__(self,
                  text: str = '', *,
                  text: str = '', *,
@@ -21,7 +22,5 @@ class Badge(TextElement):
         :param text_color: text color (either a Quasar, Tailwind, or CSS color or `None`, default: `None`)
         :param text_color: text color (either a Quasar, Tailwind, or CSS color or `None`, default: `None`)
         :param outline: use 'outline' design (colored text and borders only) (default: False)
         :param outline: use 'outline' design (colored text and borders only) (default: False)
         """
         """
-        super().__init__(tag='q-badge', text=text)
-        set_background_color(self, color)
-        set_text_color(self, text_color, prop_name='text-color')
+        super().__init__(tag='q-badge', text=text, text_color=text_color, background_color=color)
         self._props['outline'] = outline
         self._props['outline'] = outline

+ 8 - 4
nicegui/elements/button.py

@@ -1,18 +1,19 @@
 import asyncio
 import asyncio
 from typing import Any, Callable, Optional
 from typing import Any, Callable, Optional
 
 
-from ..colors import set_background_color
 from ..events import ClickEventArguments, handle_event
 from ..events import ClickEventArguments, handle_event
+from .mixins.color_elements import BackgroundColorElement
 from .mixins.disableable_element import DisableableElement
 from .mixins.disableable_element import DisableableElement
 from .mixins.text_element import TextElement
 from .mixins.text_element import TextElement
 
 
 
 
-class Button(TextElement, DisableableElement):
+class Button(TextElement, DisableableElement, BackgroundColorElement):
 
 
     def __init__(self,
     def __init__(self,
                  text: str = '', *,
                  text: str = '', *,
                  on_click: Optional[Callable[..., Any]] = None,
                  on_click: Optional[Callable[..., Any]] = None,
                  color: Optional[str] = 'primary',
                  color: Optional[str] = 'primary',
+                 icon: Optional[str] = None,
                  ) -> None:
                  ) -> None:
         """Button
         """Button
 
 
@@ -26,9 +27,12 @@ class Button(TextElement, DisableableElement):
         :param text: the label of the button
         :param text: the label of the button
         :param on_click: callback which is invoked when button is pressed
         :param on_click: callback which is invoked when button is pressed
         :param color: the color of the button (either a Quasar, Tailwind, or CSS color or `None`, default: 'primary')
         :param color: the color of the button (either a Quasar, Tailwind, or CSS color or `None`, default: 'primary')
+        :param icon: the name of an icon to be displayed on the button (default: `None`)
         """
         """
-        super().__init__(tag='q-btn', text=text)
-        set_background_color(self, color)
+        super().__init__(tag='q-btn', text=text, background_color=color)
+
+        if icon:
+            self._props['icon'] = icon
 
 
         if on_click:
         if on_click:
             self.on('click', lambda _: handle_event(on_click, ClickEventArguments(sender=self, client=self.client)))
             self.on('click', lambda _: handle_event(on_click, ClickEventArguments(sender=self, client=self.client)))

+ 2 - 2
nicegui/elements/color_input.py

@@ -31,8 +31,8 @@ class ColorInput(ValueElement, DisableableElement):
 
 
         with self.add_slot('append'):
         with self.add_slot('append'):
             self.picker = ColorPicker(on_pick=lambda e: self.set_value(e.color))
             self.picker = ColorPicker(on_pick=lambda e: self.set_value(e.color))
-            self.button = ui.button(on_click=self.open_picker) \
-                .props('icon=colorize flat round', remove='color').classes('cursor-pointer')
+            self.button = ui.button(on_click=self.open_picker, icon='colorize') \
+                .props('flat round', remove='color').classes('cursor-pointer')
 
 
     def open_picker(self) -> None:
     def open_picker(self) -> None:
         """Open the color picker"""
         """Open the color picker"""

+ 3 - 6
nicegui/elements/icon.py

@@ -1,10 +1,9 @@
 from typing import Optional
 from typing import Optional
 
 
-from ..colors import set_text_color
-from ..element import Element
+from .mixins.color_elements import TextColorElement
 
 
 
 
-class Icon(Element):
+class Icon(TextColorElement):
 
 
     def __init__(self,
     def __init__(self,
                  name: str,
                  name: str,
@@ -22,10 +21,8 @@ class Icon(Element):
         :param size: size in CSS units, including unit name or standard size name (xs|sm|md|lg|xl), examples: 16px, 2rem
         :param size: size in CSS units, including unit name or standard size name (xs|sm|md|lg|xl), examples: 16px, 2rem
         :param color: icon color (either a Quasar, Tailwind, or CSS color or `None`, default: `None`)
         :param color: icon color (either a Quasar, Tailwind, or CSS color or `None`, default: `None`)
         """
         """
-        super().__init__('q-icon')
+        super().__init__(tag='q-icon', text_color=color)
         self._props['name'] = name
         self._props['name'] = name
 
 
         if size:
         if size:
             self._props['size'] = size
             self._props['size'] = size
-
-        set_text_color(self, color)

+ 5 - 2
nicegui/elements/image.py

@@ -1,13 +1,16 @@
+from pathlib import Path
+from typing import Union
+
 from .mixins.source_element import SourceElement
 from .mixins.source_element import SourceElement
 
 
 
 
 class Image(SourceElement):
 class Image(SourceElement):
 
 
-    def __init__(self, source: str = '') -> None:
+    def __init__(self, source: Union[str, Path] = '') -> None:
         """Image
         """Image
 
 
         Displays an image.
         Displays an image.
 
 
-        :param source: the source of the image; can be a URL or a base64 string
+        :param source: the source of the image; can be a URL, local file path or a base64 string
         """
         """
         super().__init__(tag='q-img', source=source)
         super().__init__(tag='q-img', source=source)

+ 2 - 1
nicegui/elements/input.py

@@ -56,9 +56,10 @@ class Input(ValidationElement, DisableableElement):
             def find_autocompletion() -> Optional[str]:
             def find_autocompletion() -> Optional[str]:
                 if self.value:
                 if self.value:
                     needle = str(self.value).casefold()
                     needle = str(self.value).casefold()
-                    for item in autocomplete:
+                    for item in autocomplete or []:
                         if item.casefold().startswith(needle):
                         if item.casefold().startswith(needle):
                             return item
                             return item
+                return None  # required by mypy
 
 
             def autocomplete_input() -> None:
             def autocomplete_input() -> None:
                 match = find_autocompletion() or ''
                 match = find_autocompletion() or ''

+ 4 - 3
nicegui/elements/interactive_image.py

@@ -1,6 +1,7 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
-from typing import Any, Callable, Dict, List, Optional
+from pathlib import Path
+from typing import Any, Callable, Dict, List, Optional, Union
 
 
 from ..dependencies import register_component
 from ..dependencies import register_component
 from ..events import MouseEventArguments, handle_event
 from ..events import MouseEventArguments, handle_event
@@ -14,7 +15,7 @@ class InteractiveImage(SourceElement, ContentElement):
     CONTENT_PROP = 'content'
     CONTENT_PROP = 'content'
 
 
     def __init__(self,
     def __init__(self,
-                 source: str = '', *,
+                 source: Union[str, Path] = '', *,
                  content: str = '',
                  content: str = '',
                  on_mouse: Optional[Callable[..., Any]] = None,
                  on_mouse: Optional[Callable[..., Any]] = None,
                  events: List[str] = ['click'],
                  events: List[str] = ['click'],
@@ -28,7 +29,7 @@ class InteractiveImage(SourceElement, ContentElement):
         Thereby repeatedly updating the image source will automatically adapt to the available bandwidth.
         Thereby repeatedly updating the image source will automatically adapt to the available bandwidth.
         See `OpenCV Webcam <https://github.com/zauberzeug/nicegui/tree/main/examples/opencv_webcam/main.py>`_ for an example.
         See `OpenCV Webcam <https://github.com/zauberzeug/nicegui/tree/main/examples/opencv_webcam/main.py>`_ for an example.
 
 
-        :param source: the source of the image; can be an URL or a base64 string
+        :param source: the source of the image; can be an URL, local file path or a base64 string
         :param content: SVG content which should be overlayed; viewport has the same dimensions as the image
         :param content: SVG content which should be overlayed; viewport has the same dimensions as the image
         :param on_mouse: callback for mouse events (yields `type`, `image_x` and `image_y`)
         :param on_mouse: callback for mouse events (yields `type`, `image_x` and `image_y`)
         :param events: list of JavaScript events to subscribe to (default: `['click']`)
         :param events: list of JavaScript events to subscribe to (default: `['click']`)

+ 3 - 4
nicegui/elements/knob.py

@@ -1,12 +1,12 @@
 from typing import Optional
 from typing import Optional
 
 
-from ..colors import set_text_color
 from .label import Label
 from .label import Label
+from .mixins.color_elements import TextColorElement
 from .mixins.disableable_element import DisableableElement
 from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 from .mixins.value_element import ValueElement
 
 
 
 
-class Knob(ValueElement, DisableableElement):
+class Knob(ValueElement, DisableableElement, TextColorElement):
 
 
     def __init__(self,
     def __init__(self,
                  value: float = 0.0,
                  value: float = 0.0,
@@ -35,12 +35,11 @@ class Knob(ValueElement, DisableableElement):
         :param size: size in CSS units, including unit name or standard size name (xs|sm|md|lg|xl), examples: 16px, 2rem
         :param size: size in CSS units, including unit name or standard size name (xs|sm|md|lg|xl), examples: 16px, 2rem
         :param show_value: whether to show the value as text
         :param show_value: whether to show the value as text
         """
         """
-        super().__init__(tag='q-knob', value=value, on_value_change=None, throttle=0.05)
+        super().__init__(tag='q-knob', value=value, on_value_change=None, throttle=0.05, text_color=color)
 
 
         self._props['min'] = min
         self._props['min'] = min
         self._props['max'] = max
         self._props['max'] = max
         self._props['step'] = step
         self._props['step'] = step
-        set_text_color(self, color)
         self._props['show-value'] = True  # NOTE: enable default slot, e.g. for nested icon
         self._props['show-value'] = True  # NOTE: enable default slot, e.g. for nested icon
 
 
         if center_color:
         if center_color:

+ 12 - 3
nicegui/elements/link.py

@@ -10,7 +10,11 @@ register_component('link', __file__, 'link.js')
 
 
 class Link(TextElement):
 class Link(TextElement):
 
 
-    def __init__(self, text: str = '', target: Union[Callable[..., Any], str] = '#', new_tab: bool = False) -> None:
+    def __init__(self,
+                 text: str = '',
+                 target: Union[Callable[..., Any], str, Element] = '#',
+                 new_tab: bool = False,
+                 ) -> None:
         """Link
         """Link
 
 
         Create a hyperlink.
         Create a hyperlink.
@@ -19,11 +23,16 @@ class Link(TextElement):
         and link to it with `ui.link(target="#name")`.
         and link to it with `ui.link(target="#name")`.
 
 
         :param text: display text
         :param text: display text
-        :param target: page function or string that is a an absolute URL or relative path from base URL
+        :param target: page function, NiceGUI element on the same page or string that is a an absolute URL or relative path from base URL
         :param new_tab: open link in new tab (default: False)
         :param new_tab: open link in new tab (default: False)
         """
         """
         super().__init__(tag='link', text=text)
         super().__init__(tag='link', text=text)
-        self._props['href'] = target if isinstance(target, str) else globals.page_routes[target]
+        if isinstance(target, str):
+            self._props['href'] = target
+        elif isinstance(target, Element):
+            self._props['href'] = f'#{target.id}'
+        elif isinstance(target, Callable):
+            self._props['href'] = globals.page_routes[target]
         self._props['target'] = '_blank' if new_tab else '_self'
         self._props['target'] = '_blank' if new_tab else '_self'
         self._classes = ['nicegui-link']
         self._classes = ['nicegui-link']
 
 

+ 5 - 1
nicegui/elements/log.js

@@ -3,15 +3,19 @@ export default {
   data() {
   data() {
     return {
     return {
       num_lines: 0,
       num_lines: 0,
+      total_count: 0,
     };
     };
   },
   },
   mounted() {
   mounted() {
     const text = decodeURIComponent(this.lines);
     const text = decodeURIComponent(this.lines);
     this.$el.innerHTML = text;
     this.$el.innerHTML = text;
     this.num_lines = text ? text.split("\n").length : 0;
     this.num_lines = text ? text.split("\n").length : 0;
+    this.total_count = this.num_lines;
   },
   },
   methods: {
   methods: {
-    push(line) {
+    push(line, total_count) {
+      if (total_count === this.total_count) return;
+      this.total_count = total_count;
       const decoded = decodeURIComponent(line);
       const decoded = decodeURIComponent(line);
       const textarea = this.$el;
       const textarea = this.$el;
       textarea.innerHTML += (this.num_lines ? "\n" : "") + decoded;
       textarea.innerHTML += (this.num_lines ? "\n" : "") + decoded;

+ 5 - 2
nicegui/elements/log.py

@@ -22,11 +22,14 @@ class Log(Element):
         self._props['lines'] = ''
         self._props['lines'] = ''
         self._classes = ['nicegui-log']
         self._classes = ['nicegui-log']
         self.lines: deque[str] = deque(maxlen=max_lines)
         self.lines: deque[str] = deque(maxlen=max_lines)
+        self.total_count: int = 0
 
 
     def push(self, line: Any) -> None:
     def push(self, line: Any) -> None:
-        self.lines.extend(map(urllib.parse.quote, str(line).splitlines()))
+        new_lines = [urllib.parse.quote(line) for line in str(line).splitlines()]
+        self.lines.extend(new_lines)
         self._props['lines'] = '\n'.join(self.lines)
         self._props['lines'] = '\n'.join(self.lines)
-        self.run_method('push', urllib.parse.quote(str(line)))
+        self.total_count += len(new_lines)
+        self.run_method('push', urllib.parse.quote(str(line)), self.total_count)
 
 
     def clear(self) -> None:
     def clear(self) -> None:
         """Clear the log"""
         """Clear the log"""

+ 1 - 1
nicegui/elements/mermaid.py

@@ -19,4 +19,4 @@ class Mermaid(ContentElement):
 
 
     def on_content_change(self, content: str) -> None:
     def on_content_change(self, content: str) -> None:
         self._props[self.CONTENT_PROP] = content.strip()
         self._props[self.CONTENT_PROP] = content.strip()
-        self.run_method('update', content)
+        self.run_method('update', content.strip())

+ 41 - 0
nicegui/elements/mixins/color_elements.py

@@ -0,0 +1,41 @@
+from typing import Any
+
+from typing_extensions import get_args
+
+from ...element import Element
+from ...tailwind_types.background_color import BackgroundColor
+
+QUASAR_COLORS = {'primary', 'secondary', 'accent', 'dark', 'positive', 'negative', 'info', 'warning'}
+for color in {'red', 'pink', 'purple', 'deep-purple', 'indigo', 'blue', 'light-blue', 'cyan', 'teal', 'green',
+              'light-green', 'lime', 'yellow', 'amber', 'orange', 'deep-orange', 'brown', 'grey', 'blue-grey'}:
+    QUASAR_COLORS.add(color)
+    for i in range(1, 15):
+        QUASAR_COLORS.add(f'{color}-{i}')
+
+TAILWIND_COLORS = get_args(BackgroundColor)
+
+
+class BackgroundColorElement(Element):
+    BACKGROUND_COLOR_PROP = 'color'
+
+    def __init__(self, *, background_color: str, **kwargs: Any) -> None:
+        super().__init__(**kwargs)
+        if background_color in QUASAR_COLORS:
+            self._props[self.BACKGROUND_COLOR_PROP] = background_color
+        elif background_color in TAILWIND_COLORS:
+            self._classes.append(f'bg-{background_color}')
+        elif background_color is not None:
+            self._style['background-color'] = background_color
+
+
+class TextColorElement(Element):
+    TEXT_COLOR_PROP = 'color'
+
+    def __init__(self, *, text_color: str, **kwargs: Any) -> None:
+        super().__init__(**kwargs)
+        if text_color in QUASAR_COLORS:
+            self._props[self.TEXT_COLOR_PROP] = text_color
+        elif text_color in TAILWIND_COLORS:
+            self._classes.append(f'text-{text_color}')
+        elif text_color is not None:
+            self._style['color'] = text_color

+ 8 - 0
nicegui/elements/mixins/disableable_element.py

@@ -12,6 +12,14 @@ class DisableableElement(Element):
     def __init__(self, **kwargs: Any) -> None:
     def __init__(self, **kwargs: Any) -> None:
         super().__init__(**kwargs)
         super().__init__(**kwargs)
         self.enabled = True
         self.enabled = True
+        self.ignores_events_when_disabled = True
+
+    @property
+    def is_ignoring_events(self) -> bool:
+        """Return whether the element is currently ignoring events."""
+        if super().is_ignoring_events:
+            return True
+        return not self.enabled and self.ignores_events_when_disabled
 
 
     def enable(self) -> None:
     def enable(self) -> None:
         """Enable the element."""
         """Enable the element."""

+ 9 - 4
nicegui/elements/mixins/source_element.py

@@ -1,16 +1,21 @@
-from typing import Any, Callable
+from pathlib import Path
+from typing import Any, Callable, Union
 
 
 from typing_extensions import Self
 from typing_extensions import Self
 
 
+from ... import globals
 from ...binding import BindableProperty, bind, bind_from, bind_to
 from ...binding import BindableProperty, bind, bind_from, bind_to
 from ...element import Element
 from ...element import Element
+from ...helpers import is_file
 
 
 
 
 class SourceElement(Element):
 class SourceElement(Element):
     source = BindableProperty(on_change=lambda sender, source: sender.on_source_change(source))
     source = BindableProperty(on_change=lambda sender, source: sender.on_source_change(source))
 
 
-    def __init__(self, *, source: str, **kwargs: Any) -> None:
+    def __init__(self, *, source: Union[str, Path], **kwargs: Any) -> None:
         super().__init__(**kwargs)
         super().__init__(**kwargs)
+        if is_file(source):
+            source = globals.app.add_static_file(local_file=source)
         self.source = source
         self.source = source
         self._props['src'] = source
         self._props['src'] = source
 
 
@@ -64,14 +69,14 @@ class SourceElement(Element):
         bind(self, 'source', target_object, target_name, forward=forward, backward=backward)
         bind(self, 'source', target_object, target_name, forward=forward, backward=backward)
         return self
         return self
 
 
-    def set_source(self, source: str) -> None:
+    def set_source(self, source: Union[str, Path]) -> None:
         """Set the source of this element.
         """Set the source of this element.
 
 
         :param source: The new source.
         :param source: The new source.
         """
         """
         self.source = source
         self.source = source
 
 
-    def on_source_change(self, source: str) -> None:
+    def on_source_change(self, source: Union[str, Path]) -> None:
         """Called when the source of this element changes.
         """Called when the source of this element changes.
 
 
         :param source: The new source.
         :param source: The new source.

+ 4 - 4
nicegui/elements/mixins/value_element.py

@@ -1,4 +1,4 @@
-from typing import Any, Callable, Dict, Optional
+from typing import Any, Callable, Dict, List, Optional
 
 
 from typing_extensions import Self
 from typing_extensions import Self
 
 
@@ -8,9 +8,9 @@ from ...events import ValueChangeEventArguments, handle_event
 
 
 
 
 class ValueElement(Element):
 class ValueElement(Element):
-    VALUE_PROP = 'model-value'
-    EVENT_ARGS = ['value']
-    LOOPBACK = True
+    VALUE_PROP: str = 'model-value'
+    EVENT_ARGS: Optional[List[str]] = ['value']
+    LOOPBACK: bool = True
     value = BindableProperty(on_change=lambda sender, value: sender.on_value_change(value))
     value = BindableProperty(on_change=lambda sender, value: sender.on_value_change(value))
 
 
     def __init__(self, *,
     def __init__(self, *,

+ 9 - 2
nicegui/elements/mixins/visibility.py

@@ -1,4 +1,4 @@
-from typing import TYPE_CHECKING, Any, Callable
+from typing import TYPE_CHECKING, Any, Callable, cast
 
 
 from typing_extensions import Self
 from typing_extensions import Self
 
 
@@ -14,6 +14,12 @@ class Visibility:
     def __init__(self, **kwargs: Any) -> None:
     def __init__(self, **kwargs: Any) -> None:
         super().__init__(**kwargs)
         super().__init__(**kwargs)
         self.visible = True
         self.visible = True
+        self.ignores_events_when_hidden = True
+
+    @property
+    def is_ignoring_events(self) -> bool:
+        """Return whether the element is currently ignoring events."""
+        return not self.visible and self.ignores_events_when_hidden
 
 
     def bind_visibility_to(self,
     def bind_visibility_to(self,
                            target_object: Any,
                            target_object: Any,
@@ -79,11 +85,12 @@ class Visibility:
         """
         """
         self.visible = visible
         self.visible = visible
 
 
-    def on_visibility_change(self: 'Element', visible: str) -> None:
+    def on_visibility_change(self, visible: str) -> None:
         """Called when the visibility of this element changes.
         """Called when the visibility of this element changes.
 
 
         :param visible: Whether the element should be visible.
         :param visible: Whether the element should be visible.
         """
         """
+        self = cast('Element', self)
         if visible and 'hidden' in self._classes:
         if visible and 'hidden' in self._classes:
             self._classes.remove('hidden')
             self._classes.remove('hidden')
             self.update()
             self.update()

+ 5 - 7
nicegui/elements/progress.py

@@ -2,11 +2,11 @@ from typing import Optional
 
 
 from nicegui import ui
 from nicegui import ui
 
 
-from ..colors import set_text_color
+from .mixins.color_elements import TextColorElement
 from .mixins.value_element import ValueElement
 from .mixins.value_element import ValueElement
 
 
 
 
-class LinearProgress(ValueElement):
+class LinearProgress(ValueElement, TextColorElement):
     VALUE_PROP = 'value'
     VALUE_PROP = 'value'
 
 
     def __init__(self,
     def __init__(self,
@@ -25,16 +25,15 @@ class LinearProgress(ValueElement):
         :param show_value: whether to show a value label in the center (default: `True`)
         :param show_value: whether to show a value label in the center (default: `True`)
         :param color: color (either a Quasar, Tailwind, or CSS color or `None`, default: "primary")
         :param color: color (either a Quasar, Tailwind, or CSS color or `None`, default: "primary")
         """
         """
-        super().__init__(tag='q-linear-progress', value=value, on_value_change=None)
+        super().__init__(tag='q-linear-progress', value=value, on_value_change=None, text_color=color)
         self._props['size'] = size if size is not None else '20px' if show_value else '4px'
         self._props['size'] = size if size is not None else '20px' if show_value else '4px'
-        set_text_color(self, color)
 
 
         if show_value:
         if show_value:
             with self:
             with self:
                 ui.label().classes('absolute-center text-sm text-white').bind_text_from(self, 'value')
                 ui.label().classes('absolute-center text-sm text-white').bind_text_from(self, 'value')
 
 
 
 
-class CircularProgress(ValueElement):
+class CircularProgress(ValueElement, TextColorElement):
     VALUE_PROP = 'value'
     VALUE_PROP = 'value'
 
 
     def __init__(self,
     def __init__(self,
@@ -57,12 +56,11 @@ class CircularProgress(ValueElement):
         :param show_value: whether to show a value label in the center (default: `True`)
         :param show_value: whether to show a value label in the center (default: `True`)
         :param color: color (either a Quasar, Tailwind, or CSS color or `None`, default: "primary")
         :param color: color (either a Quasar, Tailwind, or CSS color or `None`, default: "primary")
         """
         """
-        super().__init__(tag='q-circular-progress', value=value, on_value_change=None)
+        super().__init__(tag='q-circular-progress', value=value, on_value_change=None, text_color=color)
         self._props['min'] = min
         self._props['min'] = min
         self._props['max'] = max
         self._props['max'] = max
         self._props['size'] = size
         self._props['size'] = size
         self._props['show-value'] = show_value
         self._props['show-value'] = show_value
-        set_text_color(self, color)
         self._props['track-color'] = 'grey-4'
         self._props['track-color'] = 'grey-4'
 
 
         if show_value:
         if show_value:

+ 4 - 4
nicegui/elements/scene_object3d.py

@@ -1,12 +1,12 @@
 import uuid
 import uuid
-from typing import TYPE_CHECKING, Any, List, Optional
+from typing import TYPE_CHECKING, Any, List, Optional, Union, cast
 
 
 import numpy as np
 import numpy as np
 
 
 from .. import globals
 from .. import globals
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
-    from .scene import Scene
+    from .scene import Scene, SceneObject
 
 
 
 
 class Object3D:
 class Object3D:
@@ -15,9 +15,9 @@ class Object3D:
         self.type = type
         self.type = type
         self.id = str(uuid.uuid4())
         self.id = str(uuid.uuid4())
         self.name: Optional[str] = None
         self.name: Optional[str] = None
-        self.scene: 'Scene' = globals.get_slot().parent
+        self.scene: 'Scene' = cast('Scene', globals.get_slot().parent)
         self.scene.objects[self.id] = self
         self.scene.objects[self.id] = self
-        self.parent: Object3D = self.scene.stack[-1]
+        self.parent: Union[Object3D, SceneObject] = self.scene.stack[-1]
         self.args: List = list(args)
         self.args: List = list(args)
         self.color: str = '#ffffff'
         self.color: str = '#ffffff'
         self.opacity: float = 1.0
         self.opacity: float = 1.0

+ 31 - 7
nicegui/elements/select.py

@@ -12,11 +12,13 @@ register_component('select', __file__, 'select.js')
 
 
 class Select(ChoiceElement, DisableableElement):
 class Select(ChoiceElement, DisableableElement):
 
 
-    def __init__(self, options: Union[List, Dict], *,
+    def __init__(self,
+                 options: Union[List, Dict], *,
                  label: Optional[str] = None,
                  label: Optional[str] = None,
                  value: Any = None,
                  value: Any = None,
                  on_change: Optional[Callable[..., Any]] = None,
                  on_change: Optional[Callable[..., Any]] = None,
                  with_input: bool = False,
                  with_input: bool = False,
+                 multiple: bool = False,
                  ) -> None:
                  ) -> None:
         """Dropdown Selection
         """Dropdown Selection
 
 
@@ -27,7 +29,15 @@ class Select(ChoiceElement, DisableableElement):
         :param value: the initial value
         :param value: the initial value
         :param on_change: callback to execute when selection changes
         :param on_change: callback to execute when selection changes
         :param with_input: whether to show an input field to filter the options
         :param with_input: whether to show an input field to filter the options
+        :param multiple: whether to allow multiple selections
         """
         """
+        self.multiple = multiple
+        if multiple:
+            self.EVENT_ARGS = None
+            if value is None:
+                value = []
+            elif not isinstance(value, list):
+                value = [value]
         super().__init__(tag='select', options=options, value=value, on_change=on_change)
         super().__init__(tag='select', options=options, value=value, on_change=on_change)
         if label is not None:
         if label is not None:
             self._props['label'] = label
             self._props['label'] = label
@@ -37,6 +47,7 @@ class Select(ChoiceElement, DisableableElement):
             self._props['hide-selected'] = True
             self._props['hide-selected'] = True
             self._props['fill-input'] = True
             self._props['fill-input'] = True
             self._props['input-debounce'] = 0
             self._props['input-debounce'] = 0
+        self._props['multiple'] = multiple
 
 
     def on_filter(self, event: Dict) -> None:
     def on_filter(self, event: Dict) -> None:
         self.options = [
         self.options = [
@@ -47,11 +58,24 @@ class Select(ChoiceElement, DisableableElement):
         self.update()
         self.update()
 
 
     def _msg_to_value(self, msg: Dict) -> Any:
     def _msg_to_value(self, msg: Dict) -> Any:
-        return self._values[msg['args']['value']]
+        if self.multiple:
+            return [self._values[arg['value']] for arg in msg['args']]
+        else:
+            return self._values[msg['args']['value']]
 
 
     def _value_to_model_value(self, value: Any) -> Any:
     def _value_to_model_value(self, value: Any) -> Any:
-        try:
-            index = self._values.index(value)
-            return {'value': index, 'label': self._labels[index]}
-        except ValueError:
-            return None
+        if self.multiple:
+            result = []
+            for item in value or []:
+                try:
+                    index = self._values.index(item)
+                    result.append({'value': index, 'label': self._labels[index]})
+                except ValueError:
+                    pass
+            return result
+        else:
+            try:
+                index = self._values.index(value)
+                return {'value': index, 'label': self._labels[index]}
+            except ValueError:
+                return None

+ 3 - 5
nicegui/elements/spinner.py

@@ -2,8 +2,7 @@ from typing import Optional
 
 
 from typing_extensions import Literal
 from typing_extensions import Literal
 
 
-from ..colors import set_text_color
-from ..element import Element
+from .mixins.color_elements import TextColorElement
 
 
 SpinnerTypes = Literal[
 SpinnerTypes = Literal[
     'default',
     'default',
@@ -32,7 +31,7 @@ SpinnerTypes = Literal[
 ]
 ]
 
 
 
 
-class Spinner(Element):
+class Spinner(TextColorElement):
 
 
     def __init__(self,
     def __init__(self,
                  type: Optional[SpinnerTypes] = 'default', *,
                  type: Optional[SpinnerTypes] = 'default', *,
@@ -49,7 +48,6 @@ class Spinner(Element):
         :param color: color of the spinner (either a Quasar, Tailwind, or CSS color or `None`, default: "primary")
         :param color: color of the spinner (either a Quasar, Tailwind, or CSS color or `None`, default: "primary")
         :param thickness: thickness of the spinner (applies to the "default" spinner only, default: 5.0)
         :param thickness: thickness of the spinner (applies to the "default" spinner only, default: 5.0)
         """
         """
-        super().__init__('q-spinner' if type == 'default' else f'q-spinner-{type}')
+        super().__init__(tag='q-spinner' if type == 'default' else f'q-spinner-{type}', text_color=color)
         self._props['size'] = size
         self._props['size'] = size
-        set_text_color(self, color)
         self._props['thickness'] = thickness
         self._props['thickness'] = thickness

+ 1 - 1
nicegui/elements/table.py

@@ -30,7 +30,7 @@ class Table(FilterElement):
         :param pagination: number of rows per page (`None` hides the pagination, 0 means "infinite"; default: `None`)
         :param pagination: number of rows per page (`None` hides the pagination, 0 means "infinite"; default: `None`)
         :param on_select: callback which is invoked when the selection changes
         :param on_select: callback which is invoked when the selection changes
 
 
-        If selection is 'single' or 'multiple', then a `selection` property is accessible containing the selected rows.
+        If selection is 'single' or 'multiple', then a `selected` property is accessible containing the selected rows.
         """
         """
         super().__init__(tag='q-table')
         super().__init__(tag='q-table')
 
 

+ 26 - 12
nicegui/elements/tabs.py

@@ -1,4 +1,6 @@
-from typing import Any, Callable, Optional
+from __future__ import annotations
+
+from typing import Any, Callable, Optional, Union
 
 
 from .. import globals
 from .. import globals
 from .mixins.disableable_element import DisableableElement
 from .mixins.disableable_element import DisableableElement
@@ -8,7 +10,7 @@ from .mixins.value_element import ValueElement
 class Tabs(ValueElement):
 class Tabs(ValueElement):
 
 
     def __init__(self, *,
     def __init__(self, *,
-                 value: Any = None,
+                 value: Union[Tab, TabPanel, None] = None,
                  on_change: Optional[Callable[..., Any]] = None,
                  on_change: Optional[Callable[..., Any]] = None,
                  ) -> None:
                  ) -> None:
         """Tabs
         """Tabs
@@ -16,11 +18,13 @@ class Tabs(ValueElement):
         This element represents `Quasar's QTabs <https://quasar.dev/vue-components/tabs#qtabs-api>`_ component.
         This element represents `Quasar's QTabs <https://quasar.dev/vue-components/tabs#qtabs-api>`_ component.
         It contains individual tabs.
         It contains individual tabs.
 
 
-        :param value: name of the tab to be initially selected
+        :param value: `ui.tab`, `ui.tab_panel`, or name of the tab to be initially selected
         :param on_change: callback to be executed when the selected tab changes
         :param on_change: callback to be executed when the selected tab changes
         """
         """
         super().__init__(tag='q-tabs', value=value, on_value_change=on_change)
         super().__init__(tag='q-tabs', value=value, on_value_change=on_change)
-        self.panels: Optional[TabPanels] = None
+
+    def _value_to_model_value(self, value: Any) -> Any:
+        return value._props['name'] if isinstance(value, Tab) or isinstance(value, TabPanel) else value
 
 
 
 
 class Tab(DisableableElement):
 class Tab(DisableableElement):
@@ -29,9 +33,9 @@ class Tab(DisableableElement):
         """Tab
         """Tab
 
 
         This element represents `Quasar's QTab <https://quasar.dev/vue-components/tabs#qtab-api>`_ component.
         This element represents `Quasar's QTab <https://quasar.dev/vue-components/tabs#qtab-api>`_ component.
-        It is a child of a `Tabs` element.
+        It is a child of a `ui.tabs` element.
 
 
-        :param name: name of the tab (the value of the `Tabs` element)
+        :param name: name of the tab (will be the value of the `ui.tabs` element)
         :param label: label of the tab (default: `None`, meaning the same as `name`)
         :param label: label of the tab (default: `None`, meaning the same as `name`)
         :param icon: icon of the tab (default: `None`)
         :param icon: icon of the tab (default: `None`)
         """
         """
@@ -47,34 +51,44 @@ class TabPanels(ValueElement):
 
 
     def __init__(self,
     def __init__(self,
                  tabs: Tabs, *,
                  tabs: Tabs, *,
-                 value: Any = None,
+                 value: Union[Tab, TabPanel, None] = None,
                  on_change: Optional[Callable[..., Any]] = None,
                  on_change: Optional[Callable[..., Any]] = None,
                  animated: bool = True,
                  animated: bool = True,
+                 keep_alive: bool = True,
                  ) -> None:
                  ) -> None:
         """Tab Panels
         """Tab Panels
 
 
         This element represents `Quasar's QTabPanels <https://quasar.dev/vue-components/tab-panels#qtabpanels-api>`_ component.
         This element represents `Quasar's QTabPanels <https://quasar.dev/vue-components/tab-panels#qtabpanels-api>`_ component.
         It contains individual tab panels.
         It contains individual tab panels.
 
 
-        :param tabs: the `Tabs` element that controls this element
-        :param value: name of the tab panel to be initially visible
+        To avoid issues with dynamic elements when switching tabs,
+        this element uses Vue's `keep-alive <https://vuejs.org/guide/built-ins/keep-alive.html>`_ component.
+        If client-side performance is an issue, you can disable this feature.
+
+        :param tabs: the `ui.tabs` element that controls this element
+        :param value: `ui.tab`, `ui.tab_panel`, or name of the tab panel to be initially visible
         :param on_change: callback to be executed when the visible tab panel changes
         :param on_change: callback to be executed when the visible tab panel changes
         :param animated: whether the tab panels should be animated (default: `True`)
         :param animated: whether the tab panels should be animated (default: `True`)
+        :param keep_alive: whether to use Vue's keep-alive component on the content (default: `True`)
         """
         """
         super().__init__(tag='q-tab-panels', value=value, on_value_change=on_change)
         super().__init__(tag='q-tab-panels', value=value, on_value_change=on_change)
         tabs.bind_value(self, 'value')
         tabs.bind_value(self, 'value')
         self._props['animated'] = animated
         self._props['animated'] = animated
+        self._props['keep-alive'] = keep_alive
+
+    def _value_to_model_value(self, value: Any) -> Any:
+        return value._props['name'] if isinstance(value, Tab) or isinstance(value, TabPanel) else value
 
 
 
 
 class TabPanel(DisableableElement):
 class TabPanel(DisableableElement):
 
 
-    def __init__(self, name: str) -> None:
+    def __init__(self, name: Union[Tab, str]) -> None:
         """Tab Panel
         """Tab Panel
 
 
         This element represents `Quasar's QTabPanel <https://quasar.dev/vue-components/tab-panels#qtabpanel-api>`_ component.
         This element represents `Quasar's QTabPanel <https://quasar.dev/vue-components/tab-panels#qtabpanel-api>`_ component.
         It is a child of a `TabPanels` element.
         It is a child of a `TabPanels` element.
 
 
-        :param name: name of the tab panel (the value of the `TabPanels` element)
+        :param name: `ui.tab` or the name of a tab element
         """
         """
         super().__init__(tag='q-tab-panel')
         super().__init__(tag='q-tab-panel')
-        self._props['name'] = name
+        self._props['name'] = name._props['name'] if isinstance(name, Tab) else name

+ 7 - 5
nicegui/elements/upload.py

@@ -1,6 +1,7 @@
-from typing import Any, Callable, Optional
+from typing import Any, Callable, Dict, Optional
 
 
-from fastapi import Request, Response
+from fastapi import Request
+from starlette.datastructures import UploadFile
 
 
 from ..dependencies import register_component
 from ..dependencies import register_component
 from ..events import EventArguments, UploadEventArguments, handle_event
 from ..events import EventArguments, UploadEventArguments, handle_event
@@ -51,14 +52,15 @@ class Upload(DisableableElement):
             self._props['max-files'] = max_files
             self._props['max-files'] = max_files
 
 
         @app.post(self._props['url'])
         @app.post(self._props['url'])
-        async def upload_route(request: Request) -> Response:
+        async def upload_route(request: Request) -> Dict[str, str]:
             for data in (await request.form()).values():
             for data in (await request.form()).values():
+                assert isinstance(data, UploadFile)
                 args = UploadEventArguments(
                 args = UploadEventArguments(
                     sender=self,
                     sender=self,
                     client=self.client,
                     client=self.client,
                     content=data.file,
                     content=data.file,
-                    name=data.filename,
-                    type=data.content_type,
+                    name=data.filename or '',
+                    type=data.content_type or '',
                 )
                 )
                 handle_event(on_upload, args)
                 handle_event(on_upload, args)
             return {'upload': 'success'}
             return {'upload': 'success'}

+ 7 - 2
nicegui/elements/video.py

@@ -1,5 +1,8 @@
 import warnings
 import warnings
+from pathlib import Path
+from typing import Union
 
 
+from .. import globals
 from ..dependencies import register_component
 from ..dependencies import register_component
 from ..element import Element
 from ..element import Element
 
 
@@ -8,7 +11,7 @@ register_component('video', __file__, 'video.js')
 
 
 class Video(Element):
 class Video(Element):
 
 
-    def __init__(self, src: str, *,
+    def __init__(self, src: Union[str, Path], *,
                  controls: bool = True,
                  controls: bool = True,
                  autoplay: bool = False,
                  autoplay: bool = False,
                  muted: bool = False,
                  muted: bool = False,
@@ -17,7 +20,7 @@ class Video(Element):
                  ) -> None:
                  ) -> None:
         """Video
         """Video
 
 
-        :param src: URL of the video source
+        :param src: URL or local file path of the video source
         :param controls: whether to show the video controls, like play, pause, and volume (default: `True`)
         :param controls: whether to show the video controls, like play, pause, and volume (default: `True`)
         :param autoplay: whether to start playing the video automatically (default: `False`)
         :param autoplay: whether to start playing the video automatically (default: `False`)
         :param muted: whether the video should be initially muted (default: `False`)
         :param muted: whether the video should be initially muted (default: `False`)
@@ -27,6 +30,8 @@ class Video(Element):
         for a list of events you can subscribe to using the generic event subscription `on()`.
         for a list of events you can subscribe to using the generic event subscription `on()`.
         """
         """
         super().__init__('video')
         super().__init__('video')
+        if Path(src).is_file():
+            src = globals.app.add_media_file(local_file=src)
         self._props['src'] = src
         self._props['src'] = src
         self._props['controls'] = controls
         self._props['controls'] = controls
         self._props['autoplay'] = autoplay
         self._props['autoplay'] = autoplay

+ 5 - 2
nicegui/event_listener.py

@@ -1,6 +1,8 @@
 import uuid
 import uuid
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
-from typing import Any, Callable, Dict, List
+from typing import Any, Callable, Dict, List, Optional
+
+from fastapi import Request
 
 
 from .helpers import KWONLY_SLOTS
 from .helpers import KWONLY_SLOTS
 
 
@@ -10,11 +12,12 @@ class EventListener:
     id: str = field(init=False)
     id: str = field(init=False)
     element_id: int
     element_id: int
     type: str
     type: str
-    args: List[str]
+    args: Optional[List[str]]
     handler: Callable
     handler: Callable
     throttle: float
     throttle: float
     leading_events: bool
     leading_events: bool
     trailing_events: bool
     trailing_events: bool
+    request: Optional[Request]
 
 
     def __post_init__(self) -> None:
     def __post_init__(self) -> None:
         self.id = str(uuid.uuid4())
         self.id = str(uuid.uuid4())

+ 9 - 7
nicegui/events.py

@@ -1,9 +1,9 @@
 from dataclasses import dataclass
 from dataclasses import dataclass
-from inspect import signature
-from typing import TYPE_CHECKING, Any, BinaryIO, Callable, Dict, List, Optional, Union
+from inspect import Parameter, signature
+from typing import TYPE_CHECKING, Any, Awaitable, BinaryIO, Callable, Dict, List, Optional, Union
 
 
 from . import background_tasks, globals
 from . import background_tasks, globals
-from .helpers import KWONLY_SLOTS, is_coroutine
+from .helpers import KWONLY_SLOTS
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from .client import Client
     from .client import Client
@@ -271,15 +271,17 @@ class KeyEventArguments(EventArguments):
 def handle_event(handler: Optional[Callable[..., Any]],
 def handle_event(handler: Optional[Callable[..., Any]],
                  arguments: Union[EventArguments, Dict], *,
                  arguments: Union[EventArguments, Dict], *,
                  sender: Optional['Element'] = None) -> None:
                  sender: Optional['Element'] = None) -> None:
+    if handler is None:
+        return
     try:
     try:
-        if handler is None:
-            return
-        no_arguments = not signature(handler).parameters
+        no_arguments = not any(p.default is Parameter.empty for p in signature(handler).parameters.values())
         sender = arguments.sender if isinstance(arguments, EventArguments) else sender
         sender = arguments.sender if isinstance(arguments, EventArguments) else sender
         assert sender is not None and sender.parent_slot is not None
         assert sender is not None and sender.parent_slot is not None
+        if sender.is_ignoring_events:
+            return
         with sender.parent_slot:
         with sender.parent_slot:
             result = handler() if no_arguments else handler(arguments)
             result = handler() if no_arguments else handler(arguments)
-        if is_coroutine(handler):
+        if isinstance(result, Awaitable):
             async def wait_for_result():
             async def wait_for_result():
                 with sender.parent_slot:
                 with sender.parent_slot:
                     await result
                     await result

+ 36 - 16
nicegui/favicon.py

@@ -1,42 +1,57 @@
+import base64
+import io
 import urllib.parse
 import urllib.parse
 from pathlib import Path
 from pathlib import Path
-from typing import TYPE_CHECKING, Optional
+from typing import TYPE_CHECKING, Optional, Tuple, Union
 
 
-from fastapi.responses import FileResponse
+from fastapi.responses import FileResponse, Response, StreamingResponse
 
 
 from . import __version__, globals
 from . import __version__, globals
+from .helpers import is_file
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from .page import page
     from .page import page
 
 
 
 
-def create_favicon_route(path: str, favicon: Optional[str]) -> None:
-    if favicon and (is_remote_url(favicon) or is_char(favicon)):
-        return
-    fallback = Path(__file__).parent / 'static' / 'favicon.ico'
-    path = f'{"" if path == "/" else path}/favicon.ico'
-    globals.app.remove_route(path)
-    globals.app.add_route(path, lambda _: FileResponse(favicon or globals.favicon or fallback))
+def create_favicon_route(path: str, favicon: Optional[Union[str, Path]]) -> None:
+    if is_file(favicon):
+        globals.app.add_route(f'{path}/favicon.ico', lambda _: FileResponse(favicon))
 
 
 
 
 def get_favicon_url(page: 'page', prefix: str) -> str:
 def get_favicon_url(page: 'page', prefix: str) -> str:
     favicon = page.favicon or globals.favicon
     favicon = page.favicon or globals.favicon
-    if favicon and is_remote_url(favicon):
-        return favicon
-    elif not favicon:
+    if not favicon:
         return f'{prefix}/_nicegui/{__version__}/static/favicon.ico'
         return f'{prefix}/_nicegui/{__version__}/static/favicon.ico'
+    favicon = str(favicon).strip()
+    if is_remote_url(favicon):
+        return favicon
     elif is_data_url(favicon):
     elif is_data_url(favicon):
         return favicon
         return favicon
     elif is_svg(favicon):
     elif is_svg(favicon):
         return svg_to_data_url(favicon)
         return svg_to_data_url(favicon)
     elif is_char(favicon):
     elif is_char(favicon):
-        return char_to_data_url(favicon)
+        return svg_to_data_url(char_to_svg(favicon))
     elif page.path == '/':
     elif page.path == '/':
         return f'{prefix}/favicon.ico'
         return f'{prefix}/favicon.ico'
     else:
     else:
         return f'{prefix}{page.path}/favicon.ico'
         return f'{prefix}{page.path}/favicon.ico'
 
 
 
 
+def get_favicon_response() -> Response:
+    if not globals.favicon:
+        raise ValueError(f'invalid favicon: {globals.favicon}')
+    favicon = str(globals.favicon).strip()
+    if is_svg(favicon):
+        return Response(favicon, media_type='image/svg+xml')
+    elif is_data_url(favicon):
+        media_type, bytes = data_url_to_bytes(favicon)
+        return StreamingResponse(io.BytesIO(bytes), media_type=media_type)
+    elif is_char(favicon):
+        return Response(char_to_svg(favicon), media_type='image/svg+xml')
+    else:
+        raise ValueError(f'invalid favicon: {favicon}')
+
+
 def is_remote_url(favicon: str) -> bool:
 def is_remote_url(favicon: str) -> bool:
     return favicon.startswith('http://') or favicon.startswith('https://')
     return favicon.startswith('http://') or favicon.startswith('https://')
 
 
@@ -53,8 +68,8 @@ def is_data_url(favicon: str) -> bool:
     return favicon.startswith('data:')
     return favicon.startswith('data:')
 
 
 
 
-def char_to_data_url(char: str) -> str:
-    svg = f'''
+def char_to_svg(char: str) -> str:
+    return f'''
         <svg viewBox="0 0 128 128" width="128" height="128" xmlns="http://www.w3.org/2000/svg" >
         <svg viewBox="0 0 128 128" width="128" height="128" xmlns="http://www.w3.org/2000/svg" >
             <style>
             <style>
                 @supports (-moz-appearance:none) {{
                 @supports (-moz-appearance:none) {{
@@ -70,9 +85,14 @@ def char_to_data_url(char: str) -> str:
             <text y=".9em" font-size="128" font-family="Georgia, sans-serif">{char}</text>
             <text y=".9em" font-size="128" font-family="Georgia, sans-serif">{char}</text>
         </svg>
         </svg>
     '''
     '''
-    return svg_to_data_url(svg)
 
 
 
 
 def svg_to_data_url(svg: str) -> str:
 def svg_to_data_url(svg: str) -> str:
     svg_urlencoded = urllib.parse.quote(svg)
     svg_urlencoded = urllib.parse.quote(svg)
     return f'data:image/svg+xml,{svg_urlencoded}'
     return f'data:image/svg+xml,{svg_urlencoded}'
+
+
+def data_url_to_bytes(data_url: str) -> Tuple[str, bytes]:
+    media_type, base64_image = data_url.split(',', 1)
+    media_type = media_type.split(':')[1].split(';')[0]
+    return media_type, base64.b64decode(base64_image)

+ 2 - 2
nicegui/functions/javascript.py

@@ -1,10 +1,10 @@
-from typing import Optional
+from typing import Any, Optional
 
 
 from .. import globals
 from .. import globals
 
 
 
 
 async def run_javascript(code: str, *,
 async def run_javascript(code: str, *,
-                         respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Optional[str]:
+                         respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Optional[Any]:
     """Run JavaScript
     """Run JavaScript
 
 
     This function runs arbitrary JavaScript code on a page that is executed in the browser.
     This function runs arbitrary JavaScript code on a page that is executed in the browser.

+ 9 - 7
nicegui/functions/refreshable.py

@@ -1,12 +1,12 @@
 from dataclasses import dataclass
 from dataclasses import dataclass
-from typing import Any, Callable, Dict, List
+from typing import Any, Awaitable, Callable, Dict, List, Tuple, Union
 
 
 from typing_extensions import Self
 from typing_extensions import Self
 
 
 from .. import background_tasks, globals
 from .. import background_tasks, globals
 from ..dependencies import register_component
 from ..dependencies import register_component
 from ..element import Element
 from ..element import Element
-from ..helpers import KWONLY_SLOTS, is_coroutine
+from ..helpers import KWONLY_SLOTS, is_coroutine_function
 
 
 register_component('refreshable', __file__, 'refreshable.js')
 register_component('refreshable', __file__, 'refreshable.js')
 
 
@@ -15,11 +15,11 @@ register_component('refreshable', __file__, 'refreshable.js')
 class RefreshableTarget:
 class RefreshableTarget:
     container: Element
     container: Element
     instance: Any
     instance: Any
-    args: List[Any]
+    args: Tuple[Any, ...]
     kwargs: Dict[str, Any]
     kwargs: Dict[str, Any]
 
 
-    def run(self, func: Callable[..., Any]) -> None:
-        if is_coroutine(func):
+    def run(self, func: Callable[..., Any]) -> Union[None, Awaitable]:
+        if is_coroutine_function(func):
             async def wait_for_result() -> None:
             async def wait_for_result() -> None:
                 with self.container:
                 with self.container:
                     if self.instance is None:
                     if self.instance is None:
@@ -33,6 +33,7 @@ class RefreshableTarget:
                     func(*self.args, **self.kwargs)
                     func(*self.args, **self.kwargs)
                 else:
                 else:
                     func(self.instance, *self.args, **self.kwargs)
                     func(self.instance, *self.args, **self.kwargs)
+            return None  # required by mypy
 
 
 
 
 class refreshable:
 class refreshable:
@@ -51,7 +52,7 @@ class refreshable:
         self.instance = instance
         self.instance = instance
         return self
         return self
 
 
-    def __call__(self, *args: Any, **kwargs: Any) -> None:
+    def __call__(self, *args: Any, **kwargs: Any) -> Union[None, Awaitable]:
         self.prune()
         self.prune()
         target = RefreshableTarget(container=Element('refreshable'), instance=self.instance, args=args, kwargs=kwargs)
         target = RefreshableTarget(container=Element('refreshable'), instance=self.instance, args=args, kwargs=kwargs)
         self.targets.append(target)
         self.targets.append(target)
@@ -64,7 +65,8 @@ class refreshable:
                 continue
                 continue
             target.container.clear()
             target.container.clear()
             result = target.run(self.func)
             result = target.run(self.func)
-            if is_coroutine(self.func):
+            if is_coroutine_function(self.func):
+                assert result is not None
                 if globals.loop and globals.loop.is_running():
                 if globals.loop and globals.loop.is_running():
                     background_tasks.create(result)
                     background_tasks.create(result)
                 else:
                 else:

+ 10 - 5
nicegui/functions/timer.py

@@ -1,10 +1,11 @@
 import asyncio
 import asyncio
 import time
 import time
-from typing import Any, Callable
+from typing import Any, Callable, Optional
 
 
 from .. import background_tasks, globals
 from .. import background_tasks, globals
 from ..binding import BindableProperty
 from ..binding import BindableProperty
-from ..helpers import is_coroutine
+from ..helpers import is_coroutine_function
+from ..slot import Slot
 
 
 
 
 class Timer:
 class Timer:
@@ -29,9 +30,9 @@ class Timer:
         :param once: whether the callback is only executed once after a delay specified by `interval` (default: `False`)
         :param once: whether the callback is only executed once after a delay specified by `interval` (default: `False`)
         """
         """
         self.interval = interval
         self.interval = interval
-        self.callback = callback
+        self.callback: Optional[Callable[..., Any]] = callback
         self.active = active
         self.active = active
-        self.slot = globals.get_slot()
+        self.slot: Optional[Slot] = globals.get_slot()
 
 
         coroutine = self._run_once if once else self._run_in_loop
         coroutine = self._run_once if once else self._run_in_loop
         if globals.state == globals.State.STARTED:
         if globals.state == globals.State.STARTED:
@@ -43,6 +44,7 @@ class Timer:
         try:
         try:
             if not await self._connected():
             if not await self._connected():
                 return
                 return
+            assert self.slot is not None
             with self.slot:
             with self.slot:
                 await asyncio.sleep(self.interval)
                 await asyncio.sleep(self.interval)
                 if globals.state not in {globals.State.STOPPING, globals.State.STOPPED}:
                 if globals.state not in {globals.State.STOPPING, globals.State.STOPPED}:
@@ -54,6 +56,7 @@ class Timer:
         try:
         try:
             if not await self._connected():
             if not await self._connected():
                 return
                 return
+            assert self.slot is not None
             with self.slot:
             with self.slot:
                 while True:
                 while True:
                     if self.slot.parent.client.id not in globals.clients:
                     if self.slot.parent.client.id not in globals.clients:
@@ -76,8 +79,9 @@ class Timer:
 
 
     async def _invoke_callback(self) -> None:
     async def _invoke_callback(self) -> None:
         try:
         try:
+            assert self.callback is not None
             result = self.callback()
             result = self.callback()
-            if is_coroutine(self.callback):
+            if is_coroutine_function(self.callback):
                 await result
                 await result
         except Exception as e:
         except Exception as e:
             globals.handle_exception(e)
             globals.handle_exception(e)
@@ -88,6 +92,7 @@ class Timer:
         See https://github.com/zauberzeug/nicegui/issues/206 for details.
         See https://github.com/zauberzeug/nicegui/issues/206 for details.
         Returns True if the client is connected, False if the client is not connected and the timer should be cancelled.
         Returns True if the client is connected, False if the client is not connected and the timer should be cancelled.
         """
         """
+        assert self.slot is not None
         if self.slot.parent.client.shared:
         if self.slot.parent.client.shared:
             return True
             return True
         else:
         else:

+ 4 - 3
nicegui/globals.py

@@ -3,7 +3,8 @@ import inspect
 import logging
 import logging
 from contextlib import contextmanager
 from contextlib import contextmanager
 from enum import Enum
 from enum import Enum
-from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Optional, Union
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterator, List, Optional, Union
 
 
 from socketio import AsyncServer
 from socketio import AsyncServer
 from uvicorn import Server
 from uvicorn import Server
@@ -35,7 +36,7 @@ ui_run_has_been_called: bool = False
 reload: bool
 reload: bool
 title: str
 title: str
 viewport: str
 viewport: str
-favicon: Optional[str]
+favicon: Optional[Union[str, Path]]
 dark: Optional[bool]
 dark: Optional[bool]
 language: Language
 language: Language
 binding_refresh_interval: float
 binding_refresh_interval: float
@@ -86,7 +87,7 @@ def get_client() -> 'Client':
 
 
 
 
 @contextmanager
 @contextmanager
-def socket_id(id: str) -> None:
+def socket_id(id: str) -> Iterator[None]:
     global _socket_id
     global _socket_id
     _socket_id = id
     _socket_id = id
     yield
     yield

+ 72 - 2
nicegui/helpers.py

@@ -1,28 +1,52 @@
 import asyncio
 import asyncio
 import functools
 import functools
 import inspect
 import inspect
+import mimetypes
 import socket
 import socket
 import sys
 import sys
 import threading
 import threading
 import time
 import time
 import webbrowser
 import webbrowser
 from contextlib import nullcontext
 from contextlib import nullcontext
-from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional, Tuple, Union
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generator, Optional, Tuple, Union
+
+from fastapi import Request
+from fastapi.responses import StreamingResponse
+from starlette.middleware import Middleware
+from starlette.middleware.sessions import SessionMiddleware
 
 
 from . import background_tasks, globals
 from . import background_tasks, globals
+from .storage import RequestTrackingMiddleware
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from .client import Client
     from .client import Client
 
 
+mimetypes.init()
+
 KWONLY_SLOTS = {'kw_only': True, 'slots': True} if sys.version_info >= (3, 10) else {}
 KWONLY_SLOTS = {'kw_only': True, 'slots': True} if sys.version_info >= (3, 10) else {}
 
 
 
 
-def is_coroutine(object: Any) -> bool:
+def is_coroutine_function(object: Any) -> bool:
+    """Check if the object is a coroutine function.
+
+    This function is needed because functools.partial is not a coroutine function, but its func attribute is.
+    Note: It will return false for coroutine objects.
+    """
     while isinstance(object, functools.partial):
     while isinstance(object, functools.partial):
         object = object.func
         object = object.func
     return asyncio.iscoroutinefunction(object)
     return asyncio.iscoroutinefunction(object)
 
 
 
 
+def is_file(path: Optional[Union[str, Path]]) -> bool:
+    """Check if the path is a file that exists."""
+    if not path:
+        return False
+    elif isinstance(path, str) and path.strip().startswith('data:'):
+        return False  # NOTE: avoid passing data URLs to Path
+    return Path(path).is_file()
+
+
 def safe_invoke(func: Union[Callable[..., Any], Awaitable], client: Optional['Client'] = None) -> None:
 def safe_invoke(func: Union[Callable[..., Any], Awaitable], client: Optional['Client'] = None) -> None:
     try:
     try:
         if isinstance(func, Awaitable):
         if isinstance(func, Awaitable):
@@ -84,3 +108,49 @@ def schedule_browser(host: str, port: int) -> Tuple[threading.Thread, threading.
     thread = threading.Thread(target=in_thread, args=(host, port), daemon=True)
     thread = threading.Thread(target=in_thread, args=(host, port), daemon=True)
     thread.start()
     thread.start()
     return thread, cancel
     return thread, cancel
+
+
+def set_storage_secret(storage_secret: Optional[str] = None) -> None:
+    """Set storage_secret and add request tracking middleware."""
+    if any(m.cls == SessionMiddleware for m in globals.app.user_middleware):
+        # NOTE not using "add_middleware" because it would be the wrong order
+        globals.app.user_middleware.append(Middleware(RequestTrackingMiddleware))
+    elif storage_secret is not None:
+        globals.app.add_middleware(RequestTrackingMiddleware)
+        globals.app.add_middleware(SessionMiddleware, secret_key=storage_secret)
+
+
+def get_streaming_response(file: Path, request: Request) -> StreamingResponse:
+    file_size = file.stat().st_size
+    start = 0
+    end = file_size - 1
+    range_header = request.headers.get('Range')
+    if range_header:
+        byte1, byte2 = range_header.split('=')[1].split('-')
+        start = int(byte1)
+        if byte2:
+            end = int(byte2)
+    content_length = end - start + 1
+    headers = {
+        'Content-Range': f'bytes {start}-{end}/{file_size}',
+        'Content-Length': str(content_length),
+        'Accept-Ranges': 'bytes',
+    }
+
+    def content_reader(file: Path, start: int, end: int, chunk_size: int = 8192) -> Generator[bytes, None, None]:
+        with open(file, 'rb') as data:
+            data.seek(start)
+            remaining_bytes = end - start + 1
+            while remaining_bytes > 0:
+                chunk = data.read(min(chunk_size, remaining_bytes))
+                if not chunk:
+                    break
+                yield chunk
+                remaining_bytes -= len(chunk)
+
+    return StreamingResponse(
+        content_reader(file, start, end),
+        media_type=mimetypes.guess_type(str(file))[0] or 'application/octet-stream',
+        headers=headers,
+        status_code=206,
+    )

+ 130 - 1
nicegui/native.py

@@ -1,10 +1,139 @@
+import asyncio
+import inspect
+import logging
+import warnings
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
-from typing import Any, Dict
+from functools import partial
+from multiprocessing import Queue
+from typing import Any, Callable, Dict, Optional, Tuple
 
 
 from .helpers import KWONLY_SLOTS
 from .helpers import KWONLY_SLOTS
 
 
+method_queue = Queue()
+response_queue = Queue()
+
+try:
+    with warnings.catch_warnings():
+        # webview depends on bottle which uses the deprecated CGI function (https://github.com/bottlepy/bottle/issues/1403)
+        warnings.filterwarnings('ignore', category=DeprecationWarning)
+        import webview
+        from webview.window import FixPoint
+
+    class WindowProxy(webview.Window):
+
+        def __init__(self) -> None:
+            pass  # NOTE we don't call super().__init__ here because this is just a proxy to the actual window
+
+        async def get_always_on_top(self) -> bool:
+            """Get whether the window is always on top."""
+            return await self._request()
+
+        def set_always_on_top(self, on_top: bool) -> None:
+            """Set whether the window is always on top."""
+            self._send(on_top)
+
+        async def get_size(self) -> Tuple[int, int]:
+            """Get the window size as tuple (width, height)."""
+            return await self._request()
+
+        async def get_position(self) -> Tuple[int, int]:
+            """Get the window position as tuple (x, y)."""
+            return await self._request()
+
+        def load_url(self, url: str) -> None:
+            self._send(url)
+
+        def load_html(self, content: str, base_uri: str = ...) -> None:
+            self._send(content, base_uri)
+
+        def load_css(self, stylesheet: str) -> None:
+            self._send(stylesheet)
+
+        def set_title(self, title: str) -> None:
+            self._send(title)
+
+        async def get_cookies(self) -> Any:
+            return await self._request()
+
+        async def get_current_url(self) -> str:
+            return await self._request()
+
+        def destroy(self) -> None:
+            self._send()
+
+        def show(self) -> None:
+            self._send()
+
+        def hide(self) -> None:
+            self._send()
+
+        def set_window_size(self, width: int, height: int) -> None:
+            self._send(width, height)
+
+        def resize(self, width: int, height: int, fix_point: FixPoint = FixPoint.NORTH | FixPoint.WEST) -> None:
+            self._send(width, height, fix_point)
+
+        def minimize(self) -> None:
+            self._send()
+
+        def restore(self) -> None:
+            self._send()
+
+        def toggle_fullscreen(self) -> None:
+            self._send()
+
+        def move(self, x: int, y: int) -> None:
+            self._send(x, y)
+
+        async def evaluate_js(self, script: str) -> str:
+            return await self._request(script)
+
+        async def create_confirmation_dialog(self, title: str, message: str) -> bool:
+            return await self._request(title, message)
+
+        async def create_file_dialog(
+            self,
+            dialog_type: int = webview.OPEN_DIALOG,
+            directory: str = '',
+            allow_multiple: bool = False,
+            save_filename: str = '',
+            file_types: Tuple[str, ...] = (),
+        ) -> Tuple[str, ...]:
+            return await self._request(
+                dialog_type=dialog_type,
+                directory=directory,
+                allow_multiple=allow_multiple,
+                save_filename=save_filename,
+                file_types=file_types,
+            )
+
+        def expose(self, function: Callable) -> None:
+            raise NotImplementedError(f'exposing "{function}" is not supported')
+
+        def _send(self, *args: Any, **kwargs: Any) -> None:
+            name = inspect.currentframe().f_back.f_code.co_name
+            method_queue.put((name, args, kwargs))
+
+        async def _request(self, *args: Any, **kwargs: Any) -> Any:
+            def wrapper(*args: Any, **kwargs: Any) -> Any:
+                try:
+                    method_queue.put((name, args, kwargs))
+                    return response_queue.get()  # wait for the method to be called and writing its result to the queue
+                except Exception:
+                    logging.exception(f'error in {name}')
+            name = inspect.currentframe().f_back.f_code.co_name
+            return await asyncio.get_event_loop().run_in_executor(None, partial(wrapper, *args, **kwargs))
+
+        def signal_server_shutdown(self) -> None:
+            self._send()
+
+except ModuleNotFoundError:
+    class WindowProxy():
+        pass  # just a dummy if webview is not installed
+
 
 
 @dataclass(**KWONLY_SLOTS)
 @dataclass(**KWONLY_SLOTS)
 class Native:
 class Native:
     start_args: Dict[str, Any] = field(default_factory=dict)
     start_args: Dict[str, Any] = field(default_factory=dict)
     window_args: Dict[str, Any] = field(default_factory=dict)
     window_args: Dict[str, Any] = field(default_factory=dict)
+    main_window: Optional[WindowProxy] = None

+ 66 - 9
nicegui/native_mode.py

@@ -1,13 +1,16 @@
 import _thread
 import _thread
-import multiprocessing
+import logging
+import multiprocessing as mp
+import queue
 import socket
 import socket
 import sys
 import sys
 import tempfile
 import tempfile
 import time
 import time
 import warnings
 import warnings
-from threading import Thread
+from threading import Event, Thread
+from typing import Any, Callable, Dict, List, Tuple
 
 
-from . import globals, helpers
+from . import globals, helpers, native
 
 
 try:
 try:
     with warnings.catch_warnings():
     with warnings.catch_warnings():
@@ -15,10 +18,16 @@ try:
         warnings.filterwarnings('ignore', category=DeprecationWarning)
         warnings.filterwarnings('ignore', category=DeprecationWarning)
         import webview
         import webview
 except ModuleNotFoundError:
 except ModuleNotFoundError:
+    class webview:
+        class Window:
+            pass
     pass
     pass
 
 
 
 
-def open_window(host: str, port: int, title: str, width: int, height: int, fullscreen: bool) -> None:
+def open_window(
+    host: str, port: int, title: str, width: int, height: int, fullscreen: bool,
+    method_queue: mp.Queue, response_queue: mp.Queue,
+) -> None:
     while not helpers.is_port_open(host, port):
     while not helpers.is_port_open(host, port):
         time.sleep(0.1)
         time.sleep(0.1)
 
 
@@ -26,13 +35,60 @@ def open_window(host: str, port: int, title: str, width: int, height: int, fulls
     window_kwargs.update(globals.app.native.window_args)
     window_kwargs.update(globals.app.native.window_args)
 
 
     try:
     try:
-        webview.create_window(**window_kwargs)
+        window = webview.create_window(**window_kwargs)
+        closing = Event()
+        window.events.closing += closing.set
+        start_window_method_executor(window, method_queue, response_queue, closing)
         webview.start(storage_path=tempfile.mkdtemp(), **globals.app.native.start_args)
         webview.start(storage_path=tempfile.mkdtemp(), **globals.app.native.start_args)
     except NameError:
     except NameError:
-        print('Native mode is not supported in this configuration. Please install pywebview to use it.')
+        logging.error('Native mode is not supported in this configuration. Please install pywebview to use it.')
         sys.exit(1)
         sys.exit(1)
 
 
 
 
+def start_window_method_executor(
+        window: webview.Window, method_queue: mp.Queue, response_queue: mp.Queue, closing: Event
+) -> None:
+    def execute(method: Callable, args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> None:
+        try:
+            response = method(*args, **kwargs)
+            if response is not None or 'dialog' in method.__name__:
+                response_queue.put(response)
+        except Exception:
+            logging.exception(f'error in window.{method.__name__}')
+
+    def window_method_executor() -> None:
+        pending_executions: List[Thread] = []
+        while not closing.is_set():
+            try:
+                method_name, args, kwargs = method_queue.get(block=False)
+                if method_name == 'signal_server_shutdown':
+                    if pending_executions:
+                        logging.warning('shutdown is possibly blocked by opened dialogs like a file picker')
+                        while pending_executions:
+                            pending_executions.pop().join()
+                elif method_name == 'get_always_on_top':
+                    response_queue.put(window.on_top)
+                elif method_name == 'set_always_on_top':
+                    window.on_top = args[0]
+                elif method_name == 'get_position':
+                    response_queue.put((int(window.x), int(window.y)))
+                elif method_name == 'get_size':
+                    response_queue.put((int(window.width), int(window.height)))
+                else:
+                    method = getattr(window, method_name)
+                    if callable(method):
+                        pending_executions.append(Thread(target=execute, args=(method, args, kwargs)))
+                        pending_executions[-1].start()
+                    else:
+                        logging.error(f'window.{method_name} is not callable')
+            except queue.Empty:
+                time.sleep(0.01)
+            except Exception:
+                logging.exception(f'error in window.{method_name}')
+
+    Thread(target=window_method_executor).start()
+
+
 def activate(host: str, port: int, title: str, width: int, height: int, fullscreen: bool) -> None:
 def activate(host: str, port: int, title: str, width: int, height: int, fullscreen: bool) -> None:
     def check_shutdown() -> None:
     def check_shutdown() -> None:
         while process.is_alive():
         while process.is_alive():
@@ -42,10 +98,11 @@ def activate(host: str, port: int, title: str, width: int, height: int, fullscre
             time.sleep(0.1)
             time.sleep(0.1)
         _thread.interrupt_main()
         _thread.interrupt_main()
 
 
-    multiprocessing.freeze_support()
-    process = multiprocessing.Process(target=open_window, args=(host, port, title, width, height, fullscreen),
-                                      daemon=False)
+    mp.freeze_support()
+    args = host, port, title, width, height, fullscreen, native.method_queue, native.response_queue
+    process = mp.Process(target=open_window, args=args, daemon=False)
     process.start()
     process.start()
+
     Thread(target=check_shutdown, daemon=True).start()
     Thread(target=check_shutdown, daemon=True).start()
 
 
 
 

+ 13 - 4
nicegui/nicegui.py

@@ -15,19 +15,19 @@ from fastapi_socketio import SocketManager
 from nicegui import json
 from nicegui import json
 from nicegui.json import NiceGUIJSONResponse
 from nicegui.json import NiceGUIJSONResponse
 
 
-from . import __version__, background_tasks, binding, globals, outbox
+from . import __version__, background_tasks, binding, favicon, globals, outbox
 from .app import App
 from .app import App
 from .client import Client
 from .client import Client
 from .dependencies import js_components, js_dependencies
 from .dependencies import js_components, js_dependencies
 from .element import Element
 from .element import Element
 from .error import error_content
 from .error import error_content
-from .helpers import safe_invoke
+from .helpers import is_file, safe_invoke
 from .page import page
 from .page import page
 
 
 globals.app = app = App(default_response_class=NiceGUIJSONResponse)
 globals.app = app = App(default_response_class=NiceGUIJSONResponse)
 # NOTE we use custom json module which wraps orjson
 # NOTE we use custom json module which wraps orjson
 socket_manager = SocketManager(app=app, mount_location='/_nicegui_ws/', json=json)
 socket_manager = SocketManager(app=app, mount_location='/_nicegui_ws/', json=json)
-globals.sio = sio = app.sio
+globals.sio = sio = socket_manager._sio
 
 
 app.add_middleware(GZipMiddleware)
 app.add_middleware(GZipMiddleware)
 static_files = StaticFiles(
 static_files = StaticFiles(
@@ -66,6 +66,13 @@ def handle_startup(with_welcome_message: bool = True) -> None:
                            'remove the guard or replace it with\n'
                            'remove the guard or replace it with\n'
                            '   if __name__ in {"__main__", "__mp_main__"}:\n'
                            '   if __name__ in {"__main__", "__mp_main__"}:\n'
                            'to allow for multiprocessing.')
                            'to allow for multiprocessing.')
+    if globals.favicon:
+        if is_file(globals.favicon):
+            globals.app.add_route('/favicon.ico', lambda _: FileResponse(globals.favicon))
+        else:
+            globals.app.add_route('/favicon.ico', lambda _: favicon.get_favicon_response())
+    else:
+        globals.app.add_route('/favicon.ico', lambda _: FileResponse(Path(__file__).parent / 'static' / 'favicon.ico'))
     globals.state = globals.State.STARTING
     globals.state = globals.State.STARTING
     globals.loop = asyncio.get_running_loop()
     globals.loop = asyncio.get_running_loop()
     with globals.index_client:
     with globals.index_client:
@@ -97,7 +104,9 @@ def print_welcome_message():
 
 
 
 
 @app.on_event('shutdown')
 @app.on_event('shutdown')
-def handle_shutdown() -> None:
+async def handle_shutdown() -> None:
+    if app.native.main_window:
+        app.native.main_window.signal_server_shutdown()
     globals.state = globals.State.STOPPING
     globals.state = globals.State.STOPPING
     with globals.index_client:
     with globals.index_client:
         for t in globals.shutdown_handlers:
         for t in globals.shutdown_handlers:

+ 212 - 0
nicegui/observables.py

@@ -0,0 +1,212 @@
+from typing import Any, Callable, Dict, Iterable, List, Set, Union, overload
+
+from typing_extensions import SupportsIndex
+
+
+class ObservableDict(dict):
+
+    def __init__(self, data: Dict, on_change: Callable) -> None:
+        super().__init__(data)
+        for key, value in self.items():
+            super().__setitem__(key, make_observable(value, on_change))
+        self.on_change = on_change
+
+    def pop(self, k: Any, d: Any = None) -> Any:
+        item = super().pop(k, d)
+        self.on_change()
+        return item
+
+    def popitem(self) -> Any:
+        item = super().popitem()
+        self.on_change()
+        return item
+
+    def update(self, *args: Any, **kwargs: Any) -> None:
+        super().update(make_observable(dict(*args, **kwargs), self.on_change))
+        self.on_change()
+
+    def clear(self) -> None:
+        super().clear()
+        self.on_change()
+
+    def setdefault(self, __key: Any, __default: Any = None) -> Any:
+        item = super().setdefault(__key, make_observable(__default, self.on_change))
+        self.on_change()
+        return item
+
+    def __setitem__(self, __key: Any, __value: Any) -> None:
+        super().__setitem__(__key, make_observable(__value, self.on_change))
+        self.on_change()
+
+    def __delitem__(self, __key: Any) -> None:
+        super().__delitem__(__key)
+        self.on_change()
+
+    def __or__(self, other: Any) -> Any:
+        return super().__or__(other)
+
+    def __ior__(self, other: Any) -> Any:
+        super().__ior__(make_observable(dict(other), self.on_change))
+        self.on_change()
+        return self
+
+
+class ObservableList(list):
+
+    def __init__(self, data: List, on_change: Callable) -> None:
+        super().__init__(data)
+        for i, item in enumerate(self):
+            super().__setitem__(i, make_observable(item, on_change))
+        self.on_change = on_change
+
+    def append(self, item: Any) -> None:
+        super().append(make_observable(item, self.on_change))
+        self.on_change()
+
+    def extend(self, iterable: Iterable) -> None:
+        super().extend(make_observable(list(iterable), self.on_change))
+        self.on_change()
+
+    def insert(self, index: SupportsIndex, object: Any) -> None:
+        super().insert(index, make_observable(object, self.on_change))
+        self.on_change()
+
+    def remove(self, value: Any) -> None:
+        super().remove(value)
+        self.on_change()
+
+    def pop(self, index: SupportsIndex = -1) -> Any:
+        item = super().pop(index)
+        self.on_change()
+        return item
+
+    def clear(self) -> None:
+        super().clear()
+        self.on_change()
+
+    def sort(self, **kwargs: Any) -> None:
+        super().sort(**kwargs)
+        self.on_change()
+
+    def reverse(self) -> None:
+        super().reverse()
+        self.on_change()
+
+    def __delitem__(self, key: Union[SupportsIndex, slice]) -> None:
+        super().__delitem__(key)
+        self.on_change()
+
+    def __setitem__(self, key: Union[SupportsIndex, slice], value: Any) -> None:
+        super().__setitem__(key, make_observable(value, self.on_change))
+        self.on_change()
+
+    def __add__(self, other: Any) -> Any:
+        return super().__add__(other)
+
+    def __iadd__(self, other: Any) -> Any:
+        super().__iadd__(make_observable(other, self.on_change))
+        self.on_change()
+        return self
+
+
+class ObservableSet(set):
+
+    def __init__(self, data: set, on_change: Callable) -> None:
+        super().__init__(data)
+        for item in self:
+            super().add(make_observable(item, on_change))
+        self.on_change = on_change
+
+    def add(self, item: Any) -> None:
+        super().add(make_observable(item, self.on_change))
+        self.on_change()
+
+    def remove(self, item: Any) -> None:
+        super().remove(item)
+        self.on_change()
+
+    def discard(self, item: Any) -> None:
+        super().discard(item)
+        self.on_change()
+
+    def pop(self) -> Any:
+        item = super().pop()
+        self.on_change()
+        return item
+
+    def clear(self) -> None:
+        super().clear()
+        self.on_change()
+
+    def update(self, *s: Iterable[Any]) -> None:
+        super().update(make_observable(set(*s), self.on_change))
+        self.on_change()
+
+    def intersection_update(self, *s: Iterable[Any]) -> None:
+        super().intersection_update(*s)
+        self.on_change()
+
+    def difference_update(self, *s: Iterable[Any]) -> None:
+        super().difference_update(*s)
+        self.on_change()
+
+    def symmetric_difference_update(self, *s: Iterable[Any]) -> None:
+        super().symmetric_difference_update(*s)
+        self.on_change()
+
+    def __or__(self, other: Any) -> Any:
+        return super().__or__(other)
+
+    def __ior__(self, other: Any) -> Any:
+        super().__ior__(make_observable(other, self.on_change))
+        self.on_change()
+        return self
+
+    def __and__(self, other: Any) -> set:
+        return super().__and__(other)
+
+    def __iand__(self, other: Any) -> Any:
+        super().__iand__(make_observable(other, self.on_change))
+        self.on_change()
+        return self
+
+    def __sub__(self, other: Any) -> set:
+        return super().__sub__(other)
+
+    def __isub__(self, other: Any) -> Any:
+        super().__isub__(make_observable(other, self.on_change))
+        self.on_change()
+        return self
+
+    def __xor__(self, other: Any) -> set:
+        return super().__xor__(other)
+
+    def __ixor__(self, other: Any) -> Any:
+        super().__ixor__(make_observable(other, self.on_change))
+        self.on_change()
+        return self
+
+
+@overload
+def make_observable(data: Dict, on_change: Callable) -> ObservableDict:
+    ...
+
+
+@overload
+def make_observable(data: List, on_change: Callable) -> ObservableList:
+    ...
+
+
+@overload
+def make_observable(data: Set, on_change: Callable) -> ObservableSet:
+    ...
+
+
+def make_observable(data: Any, on_change: Callable) -> Any:
+    if isinstance(data, dict):
+        return ObservableDict(data, on_change)
+    if isinstance(data, list):
+        return ObservableList(data, on_change)
+    if isinstance(data, set):
+        return ObservableSet(data, on_change)
+    return data

+ 3 - 3
nicegui/outbox.py

@@ -7,7 +7,7 @@ from . import globals
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from .element import Element
     from .element import Element
 
 
-ClientId = int
+ClientId = str
 ElementId = int
 ElementId = int
 MessageType = str
 MessageType = str
 Message = Tuple[ClientId, MessageType, Any]
 Message = Tuple[ClientId, MessageType, Any]
@@ -32,8 +32,8 @@ async def loop() -> None:
         coros = []
         coros = []
         try:
         try:
             for client_id, elements in update_queue.items():
             for client_id, elements in update_queue.items():
-                elements = {element_id: element._to_dict() for element_id, element in elements.items()}
-                coros.append(globals.sio.emit('update', elements, room=client_id))
+                data = {element_id: element._to_dict() for element_id, element in elements.items()}
+                coros.append(globals.sio.emit('update', data, room=client_id))
             update_queue.clear()
             update_queue.clear()
             for client_id, message_type, data in message_queue:
             for client_id, message_type, data in message_queue:
                 coros.append(globals.sio.emit(message_type, data, room=client_id))
                 coros.append(globals.sio.emit(message_type, data, room=client_id))

+ 17 - 6
nicegui/page.py

@@ -1,7 +1,8 @@
 import asyncio
 import asyncio
 import inspect
 import inspect
 import time
 import time
-from typing import Any, Callable, Optional
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Callable, Optional, Union
 
 
 from fastapi import Request, Response
 from fastapi import Request, Response
 
 
@@ -10,6 +11,9 @@ from .client import Client
 from .favicon import create_favicon_route
 from .favicon import create_favicon_route
 from .language import Language
 from .language import Language
 
 
+if TYPE_CHECKING:
+    from .api_router import APIRouter
+
 
 
 class page:
 class page:
 
 
@@ -17,16 +21,17 @@ class page:
                  path: str, *,
                  path: str, *,
                  title: Optional[str] = None,
                  title: Optional[str] = None,
                  viewport: Optional[str] = None,
                  viewport: Optional[str] = None,
-                 favicon: Optional[str] = None,
+                 favicon: Optional[Union[str, Path]] = None,
                  dark: Optional[bool] = ...,
                  dark: Optional[bool] = ...,
                  language: Language = ...,
                  language: Language = ...,
                  response_timeout: float = 3.0,
                  response_timeout: float = 3.0,
+                 api_router: Optional['APIRouter'] = None,
                  **kwargs: Any,
                  **kwargs: Any,
                  ) -> None:
                  ) -> None:
         """Page
         """Page
 
 
-        Creates a new page at the given route.
-        Each user will see a new instance of the page.
+        This decorator marks a function to be a page builder.
+        Each user accessing the given route will see a new instance of the page.
         This means it is private to the user and not shared with others 
         This means it is private to the user and not shared with others 
         (as it is done `when placing elements outside of a page decorator <https://nicegui.io/documentation#auto-index_page>`_).
         (as it is done `when placing elements outside of a page decorator <https://nicegui.io/documentation#auto-index_page>`_).
 
 
@@ -37,9 +42,10 @@ class page:
         :param dark: whether to use Quasar's dark mode (defaults to `dark` argument of `run` command)
         :param dark: whether to use Quasar's dark mode (defaults to `dark` argument of `run` command)
         :param language: language of the page (defaults to `language` argument of `run` command)
         :param language: language of the page (defaults to `language` argument of `run` command)
         :param response_timeout: maximum time for the decorated function to build the page (default: 3.0)
         :param response_timeout: maximum time for the decorated function to build the page (default: 3.0)
+        :param api_router: APIRouter instance to use, can be left `None` to use the default
         :param kwargs: additional keyword arguments passed to FastAPI's @app.get method
         :param kwargs: additional keyword arguments passed to FastAPI's @app.get method
         """
         """
-        self.path = path
+        self._path = path
         self.title = title
         self.title = title
         self.viewport = viewport
         self.viewport = viewport
         self.favicon = favicon
         self.favicon = favicon
@@ -47,9 +53,14 @@ class page:
         self.language = language
         self.language = language
         self.response_timeout = response_timeout
         self.response_timeout = response_timeout
         self.kwargs = kwargs
         self.kwargs = kwargs
+        self.api_router = api_router or globals.app.router
 
 
         create_favicon_route(self.path, favicon)
         create_favicon_route(self.path, favicon)
 
 
+    @property
+    def path(self) -> str:
+        return self.api_router.prefix + self._path
+
     def resolve_title(self) -> str:
     def resolve_title(self) -> str:
         return self.title if self.title is not None else globals.title
         return self.title if self.title is not None else globals.title
 
 
@@ -96,6 +107,6 @@ class page:
             parameters.insert(0, request)
             parameters.insert(0, request)
         decorated.__signature__ = inspect.Signature(parameters)
         decorated.__signature__ = inspect.Signature(parameters)
 
 
-        globals.app.get(self.path, **self.kwargs)(decorated)
+        self.api_router.get(self._path, **self.kwargs)(decorated)
         globals.page_routes[func] = self.path
         globals.page_routes[func] = self.path
         return func
         return func

+ 27 - 5
nicegui/run.py

@@ -1,24 +1,41 @@
 import logging
 import logging
 import multiprocessing
 import multiprocessing
 import os
 import os
+import socket
 import sys
 import sys
-from typing import Any, List, Optional, Tuple
+from pathlib import Path
+from typing import Any, List, Optional, Tuple, Union
 
 
 import __main__
 import __main__
 import uvicorn
 import uvicorn
 from uvicorn.main import STARTUP_FAILURE
 from uvicorn.main import STARTUP_FAILURE
 from uvicorn.supervisors import ChangeReload, Multiprocess
 from uvicorn.supervisors import ChangeReload, Multiprocess
 
 
-from . import globals, helpers, native_mode
+from . import globals, helpers
+from . import native as native_module
+from . import native_mode
 from .language import Language
 from .language import Language
 
 
 
 
+class Server(uvicorn.Server):
+
+    def run(self, sockets: Optional[List[socket.socket]] = None) -> None:
+        globals.server = self
+        native_module.method_queue = self.config.method_queue
+        native_module.response_queue = self.config.response_queue
+        if native_module.method_queue is not None:
+            globals.app.native.main_window = native_module.WindowProxy()
+
+        helpers.set_storage_secret(self.config.storage_secret)
+        super().run(sockets=sockets)
+
+
 def run(*,
 def run(*,
         host: Optional[str] = None,
         host: Optional[str] = None,
         port: int = 8080,
         port: int = 8080,
         title: str = 'NiceGUI',
         title: str = 'NiceGUI',
         viewport: str = 'width=device-width, initial-scale=1',
         viewport: str = 'width=device-width, initial-scale=1',
-        favicon: Optional[str] = None,
+        favicon: Optional[Union[str, Path]] = None,
         dark: Optional[bool] = False,
         dark: Optional[bool] = False,
         language: Language = 'en-US',
         language: Language = 'en-US',
         binding_refresh_interval: float = 0.1,
         binding_refresh_interval: float = 0.1,
@@ -33,6 +50,7 @@ def run(*,
         uvicorn_reload_excludes: str = '.*, .py[cod], .sw.*, ~*',
         uvicorn_reload_excludes: str = '.*, .py[cod], .sw.*, ~*',
         exclude: str = '',
         exclude: str = '',
         tailwind: bool = True,
         tailwind: bool = True,
+        storage_secret: Optional[str] = None,
         **kwargs: Any,
         **kwargs: Any,
         ) -> None:
         ) -> None:
     '''ui.run
     '''ui.run
@@ -59,7 +77,8 @@ def run(*,
     :param exclude: comma-separated string to exclude elements (with corresponding JavaScript libraries) to save bandwidth
     :param exclude: comma-separated string to exclude elements (with corresponding JavaScript libraries) to save bandwidth
       (possible entries: aggrid, audio, chart, colors, interactive_image, joystick, keyboard, log, markdown, mermaid, plotly, scene, video)
       (possible entries: aggrid, audio, chart, colors, interactive_image, joystick, keyboard, log, markdown, mermaid, plotly, scene, video)
     :param tailwind: whether to use Tailwind (experimental, default: `True`)
     :param tailwind: whether to use Tailwind (experimental, default: `True`)
-    :param kwargs: additional keyword arguments are passed to `uvicorn.run`
+    :param storage_secret: secret key for browser based storage (default: `None`, a value is required to enable ui.storage.individual and ui.storage.browser)
+    :param kwargs: additional keyword arguments are passed to `uvicorn.run`    
     '''
     '''
     globals.ui_run_has_been_called = True
     globals.ui_run_has_been_called = True
     globals.reload = reload
     globals.reload = reload
@@ -115,7 +134,10 @@ def run(*,
         log_level=uvicorn_logging_level,
         log_level=uvicorn_logging_level,
         **kwargs,
         **kwargs,
     )
     )
-    globals.server = uvicorn.Server(config=config)
+    config.storage_secret = storage_secret
+    config.method_queue = native_module.method_queue if native else None
+    config.response_queue = native_module.response_queue if native else None
+    globals.server = Server(config=config)
 
 
     if (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".')
         logging.warning('You must pass the application as an import string to enable "reload" or "workers".')

+ 6 - 2
nicegui/run_with.py

@@ -1,8 +1,10 @@
-from typing import Optional
+from pathlib import Path
+from typing import Optional, Union
 
 
 from fastapi import FastAPI
 from fastapi import FastAPI
 
 
 from nicegui import globals
 from nicegui import globals
+from nicegui.helpers import set_storage_secret
 from nicegui.language import Language
 from nicegui.language import Language
 from nicegui.nicegui import handle_shutdown, handle_startup
 from nicegui.nicegui import handle_shutdown, handle_startup
 
 
@@ -11,12 +13,13 @@ def run_with(
     app: FastAPI, *,
     app: FastAPI, *,
     title: str = 'NiceGUI',
     title: str = 'NiceGUI',
     viewport: str = 'width=device-width, initial-scale=1',
     viewport: str = 'width=device-width, initial-scale=1',
-    favicon: Optional[str] = None,
+    favicon: Optional[Union[str, Path]] = None,
     dark: Optional[bool] = False,
     dark: Optional[bool] = False,
     language: Language = 'en-US',
     language: Language = 'en-US',
     binding_refresh_interval: float = 0.1,
     binding_refresh_interval: float = 0.1,
     exclude: str = '',
     exclude: str = '',
     mount_path: str = '/',
     mount_path: str = '/',
+    storage_secret: Optional[str] = None,
 ) -> None:
 ) -> None:
     globals.ui_run_has_been_called = True
     globals.ui_run_has_been_called = True
     globals.title = title
     globals.title = title
@@ -28,6 +31,7 @@ def run_with(
     globals.excludes = [e.strip() for e in exclude.split(',')]
     globals.excludes = [e.strip() for e in exclude.split(',')]
     globals.tailwind = True
     globals.tailwind = True
 
 
+    set_storage_secret(storage_secret)
     app.on_event('startup')(lambda: handle_startup(with_welcome_message=False))
     app.on_event('startup')(lambda: handle_startup(with_welcome_message=False))
     app.on_event('shutdown')(lambda: handle_shutdown())
     app.on_event('shutdown')(lambda: handle_shutdown())
 
 

+ 128 - 0
nicegui/storage.py

@@ -0,0 +1,128 @@
+import contextvars
+import json
+import uuid
+from collections.abc import MutableMapping
+from pathlib import Path
+from typing import Any, Dict, Iterator, Optional, Union
+
+import aiofiles
+from fastapi import Request
+from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
+from starlette.responses import Response
+
+from . import background_tasks, globals, observables
+
+request_contextvar: contextvars.ContextVar[Optional[Request]] = contextvars.ContextVar('request_var', default=None)
+
+
+class ReadOnlyDict(MutableMapping):
+
+    def __init__(self, data: Dict[Any, Any], write_error_message: str = 'Read-only dict') -> None:
+        self._data: Dict[Any, Any] = data
+        self._write_error_message: str = write_error_message
+
+    def __getitem__(self, item: Any) -> Any:
+        return self._data[item]
+
+    def __setitem__(self, key: Any, value: Any) -> None:
+        raise TypeError(self._write_error_message)
+
+    def __delitem__(self, key: Any) -> None:
+        raise TypeError(self._write_error_message)
+
+    def __iter__(self) -> Iterator:
+        return iter(self._data)
+
+    def __len__(self) -> int:
+        return len(self._data)
+
+
+class PersistentDict(observables.ObservableDict):
+
+    def __init__(self, filepath: Path) -> None:
+        self.filepath = filepath
+        data = json.loads(filepath.read_text()) if filepath.exists() else {}
+        super().__init__(data, self.backup)
+
+    def backup(self) -> None:
+        async def backup() -> None:
+            async with aiofiles.open(self.filepath, 'w') as f:
+                await f.write(json.dumps(self))
+        if globals.loop:
+            background_tasks.create_lazy(backup(), name=self.filepath.stem)
+        else:
+            globals.app.on_startup(backup())
+
+
+class RequestTrackingMiddleware(BaseHTTPMiddleware):
+
+    async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
+        request_contextvar.set(request)
+        if 'id' not in request.session:
+            request.session['id'] = str(uuid.uuid4())
+        request.state.responded = False
+        response = await call_next(request)
+        request.state.responded = True
+        return response
+
+
+class Storage:
+
+    def __init__(self) -> None:
+        self.storage_dir = Path('.nicegui')
+        self.storage_dir.mkdir(exist_ok=True)
+        self._general = PersistentDict(self.storage_dir / 'storage_general.json')
+        self._users: Dict[str, PersistentDict] = {}
+
+    @property
+    def browser(self) -> Union[ReadOnlyDict, Dict]:
+        """Small storage that is saved directly within the user's browser (encrypted cookie).
+
+        The data is shared between all browser tabs and can only be modified before the initial request has been submitted.
+        Therefore it is normally better to use `app.storage.user` instead,
+        which can be modified anytime, reduces overall payload, improves security and has larger storage capacity.
+        """
+        request: Optional[Request] = request_contextvar.get()
+        if request is None:
+            if globals.get_client() == globals.index_client:
+                raise RuntimeError('app.storage.browser can only be used with page builder functions '
+                                   '(https://nicegui.io/documentation/page)')
+            else:
+                raise RuntimeError('app.storage.browser needs a storage_secret passed in ui.run()')
+        if request.state.responded:
+            return ReadOnlyDict(
+                request.session,
+                'the response to the browser has already been built, so modifications cannot be sent back anymore'
+            )
+        return request.session
+
+    @property
+    def user(self) -> Dict:
+        """Individual user storage that is persisted on the server (where NiceGUI is executed).
+
+        The data is stored in a file on the server.
+        It is shared between all browser tabs by identifying the user via session cookie ID.
+        """
+        request: Optional[Request] = request_contextvar.get()
+        if request is None:
+            if globals.get_client() == globals.index_client:
+                raise RuntimeError('app.storage.user can only be used with page builder functions '
+                                   '(https://nicegui.io/documentation/page)')
+            else:
+                raise RuntimeError('app.storage.user needs a storage_secret passed in ui.run()')
+        id = request.session['id']
+        if id not in self._users:
+            self._users[id] = PersistentDict(self.storage_dir / f'storage_user_{id}.json')
+        return self._users[id]
+
+    @property
+    def general(self) -> Dict:
+        """General storage shared between all users that is persisted on the server (where NiceGUI is executed)."""
+        return self._general
+
+    def clear(self) -> None:
+        """Clears all storage."""
+        self._general.clear()
+        self._users.clear()
+        for filepath in self.storage_dir.glob('storage_*.json'):
+            filepath.unlink()

+ 5 - 3
nicegui/tailwind.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
-from typing import TYPE_CHECKING, List, Optional, overload
+from typing import TYPE_CHECKING, List, Optional, Union, overload
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from .element import Element
     from .element import Element
@@ -177,10 +177,10 @@ class PseudoElement:
 class Tailwind:
 class Tailwind:
 
 
     def __init__(self, _element: Optional['Element'] = None) -> None:
     def __init__(self, _element: Optional['Element'] = None) -> None:
-        self.element = PseudoElement() if _element is None else _element
+        self.element: Union[PseudoElement, Element] = PseudoElement() if _element is None else _element
 
 
     @overload
     @overload
-    def __call__(self, Tailwind) -> Tailwind:
+    def __call__(self, tailwind: Tailwind) -> Tailwind:
         ...
         ...
 
 
     @overload
     @overload
@@ -188,6 +188,8 @@ class Tailwind:
         ...
         ...
 
 
     def __call__(self, *args) -> Tailwind:
     def __call__(self, *args) -> Tailwind:
+        if not args:
+            return self
         if isinstance(args[0], Tailwind):
         if isinstance(args[0], Tailwind):
             args[0].apply(self.element)
             args[0].apply(self.element)
         else:
         else:

+ 5 - 4
nicegui/templates/index.html

@@ -24,6 +24,11 @@
       <span>Connection lost.</span>
       <span>Connection lost.</span>
       <span>Trying to reconnect...</span>
       <span>Trying to reconnect...</span>
     </div>
     </div>
+    <script>
+      function getElement(id) {
+        return window.app.$refs["r" + id];
+      }
+    </script>
     <script type="module">
     <script type="module">
       const True = true;
       const True = true;
       const False = false;
       const False = false;
@@ -120,10 +125,6 @@
         return Vue.h(Vue.resolveComponent(element.tag), props, slots);
         return Vue.h(Vue.resolveComponent(element.tag), props, slots);
       }
       }
 
 
-      function getElement(id) {
-        return window.app.$refs['r' + id];
-      }
-
       function runJavascript(code, request_id) {
       function runJavascript(code, request_id) {
         (new Promise((resolve) =>resolve(eval(code)))).catch((reason) => {
         (new Promise((resolve) =>resolve(eval(code)))).catch((reason) => {
           if(reason instanceof SyntaxError)
           if(reason instanceof SyntaxError)

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 187 - 260
poetry.lock


+ 2 - 0
pyproject.toml

@@ -28,6 +28,8 @@ plotly = "^5.13.0"
 orjson = {version = "^3.8.6", markers = "platform_machine != 'i386' and platform_machine != 'i686'"} # orjson does not support 32bit
 orjson = {version = "^3.8.6", markers = "platform_machine != 'i386' and platform_machine != 'i686'"} # orjson does not support 32bit
 pywebview = "^4.0.2"
 pywebview = "^4.0.2"
 importlib_metadata = { version = "^6.0.0", markers = "python_version ~= '3.7'" } # Python 3.7 has no importlib.metadata
 importlib_metadata = { version = "^6.0.0", markers = "python_version ~= '3.7'" } # Python 3.7 has no importlib.metadata
+itsdangerous = "^2.1.2"
+aiofiles = "^23.1.0"
 
 
 [tool.poetry.group.dev.dependencies]
 [tool.poetry.group.dev.dependencies]
 icecream = "^2.1.0"
 icecream = "^2.1.0"

+ 4 - 0
test_startup.sh

@@ -36,6 +36,10 @@ do
         # NOTE: chat_with_ai example is not working with python 3.7
         # NOTE: chat_with_ai example is not working with python 3.7
         continue
         continue
     fi
     fi
+    if [[ $path == *"ai_interface"* ]] && [[ $(python3 --version) == *"3.7"* ]]; then
+        # NOTE: ai_interface example is not working with python 3.7
+        continue
+    fi
     if test -f $path/start.sh; then
     if test -f $path/start.sh; then
         check $path/start.sh dev || error=1 
         check $path/start.sh dev || error=1 
     elif test -f $path/main.py; then
     elif test -f $path/main.py; then

+ 5 - 0
tests/conftest.py

@@ -39,7 +39,12 @@ def selenium(selenium: webdriver.Chrome) -> webdriver.Chrome:
 def reset_globals() -> Generator[None, None, None]:
 def reset_globals() -> Generator[None, None, None]:
     for path in {'/'}.union(globals.page_routes.values()):
     for path in {'/'}.union(globals.page_routes.values()):
         globals.app.remove_route(path)
         globals.app.remove_route(path)
+    globals.app.middleware_stack = None
+    globals.app.user_middleware.clear()
+    # NOTE favicon routes must be removed separately because they are not "pages"
+    [globals.app.routes.remove(r) for r in globals.app.routes if r.path.endswith('/favicon.ico')]
     importlib.reload(globals)
     importlib.reload(globals)
+    globals.app.storage.clear()
     globals.index_client = Client(page('/'), shared=True).__enter__()
     globals.index_client = Client(page('/'), shared=True).__enter__()
     globals.app.get('/')(globals.index_client.build_response)
     globals.app.get('/')(globals.index_client.build_response)
 
 

+ 2 - 1
tests/requirements.txt

@@ -3,4 +3,5 @@ pytest-selenium
 pytest-asyncio
 pytest-asyncio
 selenium
 selenium
 autopep8
 autopep8
-icecream
+icecream
+beautifulsoup4

+ 5 - 3
tests/screen.py

@@ -14,23 +14,25 @@ from selenium.webdriver.remote.webelement import WebElement
 
 
 from nicegui import globals, ui
 from nicegui import globals, ui
 
 
+from .test_helpers import TEST_DIR
+
 PORT = 3392
 PORT = 3392
 IGNORED_CLASSES = ['row', 'column', 'q-card', 'q-field', 'q-field__label', 'q-input']
 IGNORED_CLASSES = ['row', 'column', 'q-card', 'q-field', 'q-field__label', 'q-input']
 
 
 
 
 class Screen:
 class Screen:
     IMPLICIT_WAIT = 4
     IMPLICIT_WAIT = 4
-    SCREENSHOT_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'screenshots')
-    UI_RUN_KWARGS = {'port': PORT, 'show': False, 'reload': False}
+    SCREENSHOT_DIR = TEST_DIR / 'screenshots'
 
 
     def __init__(self, selenium: webdriver.Chrome, caplog: pytest.LogCaptureFixture) -> None:
     def __init__(self, selenium: webdriver.Chrome, caplog: pytest.LogCaptureFixture) -> None:
         self.selenium = selenium
         self.selenium = selenium
         self.caplog = caplog
         self.caplog = caplog
         self.server_thread = None
         self.server_thread = None
+        self.ui_run_kwargs = {'port': PORT, 'show': False, 'reload': False}
 
 
     def start_server(self) -> None:
     def start_server(self) -> None:
         '''Start the webserver in a separate thread. This is the equivalent of `ui.run()` in a normal script.'''
         '''Start the webserver in a separate thread. This is the equivalent of `ui.run()` in a normal script.'''
-        self.server_thread = threading.Thread(target=ui.run, kwargs=self.UI_RUN_KWARGS)
+        self.server_thread = threading.Thread(target=ui.run, kwargs=self.ui_run_kwargs)
         self.server_thread.start()
         self.server_thread.start()
 
 
     @property
     @property

+ 31 - 0
tests/test_api_router.py

@@ -0,0 +1,31 @@
+
+from nicegui import APIRouter, app, ui
+
+from .screen import Screen
+
+
+def test_prefix(screen: Screen):
+    router = APIRouter(prefix='/some-prefix')
+
+    @router.page('/')
+    def page():
+        ui.label('Hello, world!')
+
+    app.include_router(router)
+
+    screen.open('/some-prefix')
+    screen.should_contain('NiceGUI')
+    screen.should_contain('Hello, world!')
+
+
+def test_passing_page_parameters(screen: Screen):
+    router = APIRouter()
+
+    @router.page('/', title='My Custom Title')
+    def page():
+        ui.label('Hello, world!')
+
+    app.include_router(router)
+
+    screen.open('/')
+    screen.should_contain('My Custom Title')

+ 24 - 0
tests/test_events.py

@@ -1,6 +1,8 @@
 import asyncio
 import asyncio
 
 
+import pytest
 from selenium.webdriver.common.by import By
 from selenium.webdriver.common.by import By
+from typing_extensions import Literal
 
 
 from nicegui import ui
 from nicegui import ui
 from nicegui.events import ClickEventArguments
 from nicegui.events import ClickEventArguments
@@ -26,21 +28,29 @@ async def click_async_with_args(_: ClickEventArguments):
     ui.label('click_async_with_args')
     ui.label('click_async_with_args')
 
 
 
 
+async def click_lambda_with_async_and_parameters(msg: str):
+    await asyncio.sleep(0.1)
+    ui.label(f'click_lambda_with_async_and_parameters: {msg}')
+
+
 def test_click_events(screen: Screen):
 def test_click_events(screen: Screen):
     ui.button('click_sync_no_args', on_click=click_sync_no_args)
     ui.button('click_sync_no_args', on_click=click_sync_no_args)
     ui.button('click_sync_with_args', on_click=click_sync_with_args)
     ui.button('click_sync_with_args', on_click=click_sync_with_args)
     ui.button('click_async_no_args', on_click=click_async_no_args)
     ui.button('click_async_no_args', on_click=click_async_no_args)
     ui.button('click_async_with_args', on_click=click_async_with_args)
     ui.button('click_async_with_args', on_click=click_async_with_args)
+    ui.button('click_lambda_with_async_and_parameters', on_click=lambda: click_lambda_with_async_and_parameters('works'))
 
 
     screen.open('/')
     screen.open('/')
     screen.click('click_sync_no_args')
     screen.click('click_sync_no_args')
     screen.click('click_sync_with_args')
     screen.click('click_sync_with_args')
     screen.click('click_async_no_args')
     screen.click('click_async_no_args')
     screen.click('click_async_with_args')
     screen.click('click_async_with_args')
+    screen.click('click_lambda_with_async_and_parameters')
     screen.should_contain('click_sync_no_args')
     screen.should_contain('click_sync_no_args')
     screen.should_contain('click_sync_with_args')
     screen.should_contain('click_sync_with_args')
     screen.should_contain('click_async_no_args')
     screen.should_contain('click_async_no_args')
     screen.should_contain('click_async_with_args')
     screen.should_contain('click_async_with_args')
+    screen.should_contain('click_lambda_with_async_and_parameters: works')
 
 
 
 
 def test_generic_events(screen: Screen):
 def test_generic_events(screen: Screen):
@@ -150,3 +160,17 @@ def test_throttling_variants(screen: Screen):
     assert events == []
     assert events == []
     screen.wait(1.1)
     screen.wait(1.1)
     assert events == [3]
     assert events == [3]
+
+
+@pytest.mark.parametrize('attribute', ['disabled', 'hidden'])
+def test_server_side_validation(screen: Screen, attribute: Literal['disabled', 'hidden']):
+    b = ui.button('Button', on_click=lambda: ui.label('Success'))
+    b.disable() if attribute == 'disabled' else b.set_visibility(False)
+    ui.button('Hack', on_click=lambda: ui.run_javascript(f'''
+        getElement({b.id}).$emit("click", {{"id": {b.id}, "listener_id": "{list(b._event_listeners.keys())[0]}"}});
+    ''', respond=False))
+
+    screen.open('/')
+    screen.click('Hack')
+    screen.wait(0.5)
+    screen.should_not_contain('Success')

+ 92 - 0
tests/test_favicon.py

@@ -0,0 +1,92 @@
+from pathlib import Path
+from typing import Union
+
+import requests
+from bs4 import BeautifulSoup
+
+from nicegui import favicon, ui
+
+from .screen import PORT, Screen
+
+DEFAULT_FAVICON_PATH = Path(__file__).parent.parent / 'nicegui' / 'static' / 'favicon.ico'
+LOGO_FAVICON_PATH = Path(__file__).parent.parent / 'website' / 'static' / 'logo_square.png'
+
+
+def assert_favicon_url_starts_with(screen: Screen, content: str):
+    soup = BeautifulSoup(screen.selenium.page_source, 'html.parser')
+    icon_link = soup.find("link", rel="icon")
+    assert icon_link['href'].startswith(content)
+
+
+def assert_favicon(content: Union[Path, str, bytes], url_path: str = '/favicon.ico'):
+    response = requests.get(f'http://localhost:{PORT}{url_path}')
+    assert response.status_code == 200
+    if isinstance(content, Path):
+        assert content.read_bytes() == response.content
+    elif isinstance(content, str):
+        assert content == response.text
+    elif isinstance(content, bytes):
+        assert content == response.content
+    else:
+        raise TypeError(f'Unexpected type: {type(content)}')
+
+
+def test_default(screen: Screen):
+    ui.label('Hello, world')
+
+    screen.open('/')
+    assert_favicon(DEFAULT_FAVICON_PATH)
+
+
+def test_emoji(screen: Screen):
+    ui.label('Hello, world')
+
+    screen.ui_run_kwargs['favicon'] = '👋'
+    screen.open('/')
+    assert_favicon_url_starts_with(screen, 'data:image/svg+xml')
+    assert_favicon(favicon.char_to_svg('👋'))
+
+
+def test_data_url(screen: Screen):
+    ui.label('Hello, world')
+
+    icon = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=='
+    screen.ui_run_kwargs['favicon'] = icon
+    screen.open('/')
+    assert_favicon_url_starts_with(screen, 'data:image/png;base64')
+    _, bytes = favicon.data_url_to_bytes(icon)
+    assert_favicon(bytes)
+
+
+def test_custom_file(screen: Screen):
+    ui.label('Hello, world')
+
+    screen.ui_run_kwargs['favicon'] = LOGO_FAVICON_PATH
+    screen.open('/')
+    assert_favicon_url_starts_with(screen, '/favicon.ico')
+    assert_favicon(screen.ui_run_kwargs['favicon'])
+
+
+def test_page_specific_icon(screen: Screen):
+    @ui.page('/subpage', favicon=LOGO_FAVICON_PATH)
+    def sub():
+        ui.label('Subpage')
+
+    ui.label('Main')
+
+    screen.open('/subpage')
+    assert_favicon(LOGO_FAVICON_PATH, url_path='/subpage/favicon.ico')
+    screen.open('/')
+
+
+def test_page_specific_emoji(screen: Screen):
+    @ui.page('/subpage', favicon='👋')
+    def sub():
+        ui.label('Subpage')
+
+    ui.label('Main')
+
+    screen.open('/subpage')
+    assert_favicon_url_starts_with(screen, 'data:image/svg+xml')
+    screen.open('/')
+    assert_favicon(DEFAULT_FAVICON_PATH)

+ 3 - 0
tests/test_helpers.py

@@ -2,9 +2,12 @@ import contextlib
 import socket
 import socket
 import time
 import time
 import webbrowser
 import webbrowser
+from pathlib import Path
 
 
 from nicegui import helpers
 from nicegui import helpers
 
 
+TEST_DIR = Path(__file__).parent
+
 
 
 def test_is_port_open():
 def test_is_port_open():
     with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
     with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:

+ 18 - 0
tests/test_link.py

@@ -60,3 +60,21 @@ def test_updating_href_prop(screen: Screen):
     assert screen.find('nicegui.io').get_attribute('href') == 'https://nicegui.io/'
     assert screen.find('nicegui.io').get_attribute('href') == 'https://nicegui.io/'
     screen.click('change href')
     screen.click('change href')
     assert screen.find('nicegui.io').get_attribute('href') == 'https://github.com/zauberzeug/nicegui'
     assert screen.find('nicegui.io').get_attribute('href') == 'https://github.com/zauberzeug/nicegui'
+
+
+def test_link_to_elements(screen: Screen):
+    navigation = ui.row()
+    for i in range(100):
+        ui.label(f'label {i}')
+    link = ui.link('goto top', navigation)
+    with navigation:
+        ui.link('goto bottom', link)
+
+    screen.open('/')
+    assert screen.selenium.execute_script('return window.scrollY') == 0
+    screen.click('goto bottom')
+    screen.wait(0.5)
+    assert screen.selenium.execute_script('return window.scrollY') > 100
+    screen.click('goto top')
+    screen.wait(0.5)
+    assert screen.selenium.execute_script('return window.scrollY') < 100

+ 9 - 0
tests/test_log.py

@@ -54,3 +54,12 @@ def test_special_characters(screen: Screen):
     screen.should_contain('50%')
     screen.should_contain('50%')
     screen.click('push')
     screen.click('push')
     screen.should_contain('100%')
     screen.should_contain('100%')
+
+
+def test_line_duplication_bug_906(screen: Screen):
+    ui.button('Log', on_click=lambda: ui.log().push('Hi!'))
+
+    screen.open('/')
+    screen.click('Log')
+    screen.should_contain('Hi!')
+    screen.should_not_contain('Hi!\nHi!')

+ 121 - 0
tests/test_observables.py

@@ -0,0 +1,121 @@
+import sys
+
+from nicegui.observables import make_observable
+
+count = 0
+
+
+def reset_counter():
+    global count
+    count = 0
+
+
+def increment_counter():
+    global count
+    count += 1
+
+
+def test_observable_dict():
+    reset_counter()
+    data = make_observable({}, increment_counter)
+    data['a'] = 1
+    assert count == 1
+    del data['a']
+    assert count == 2
+    data.update({'b': 2, 'c': 3})
+    assert count == 3
+    data.pop('b')
+    assert count == 4
+    data.popitem()
+    assert count == 5
+    data.clear()
+    assert count == 6
+    data.setdefault('a', 1)
+    assert count == 7
+    if sys.version_info >= (3, 9):
+        data |= {'b': 2}
+        assert count == 8
+
+
+def test_observable_list():
+    reset_counter()
+    data = make_observable([], increment_counter)
+    data.append(1)
+    assert count == 1
+    data.extend([2, 3, 4])
+    assert count == 2
+    data.insert(0, 0)
+    assert count == 3
+    data.remove(1)
+    assert count == 4
+    data.pop()
+    assert count == 5
+    data.sort()
+    assert count == 6
+    data.reverse()
+    assert count == 7
+    data[0] = 1
+    assert count == 8
+    data[0:2] = [1, 2, 3]
+    assert count == 9
+    del data[0]
+    assert count == 10
+    del data[0:1]
+    assert count == 11
+    data.clear()
+    assert count == 12
+    data += [1, 2, 3]
+    assert count == 13
+
+
+def test_observable_set():
+    reset_counter()
+    data = make_observable({1, 2, 3, 4, 5}, increment_counter)
+    data.add(1)
+    assert count == 1
+    data.remove(1)
+    assert count == 2
+    data.discard(2)
+    assert count == 3
+    data.pop()
+    assert count == 4
+    data.clear()
+    assert count == 5
+    data.update({1, 2, 3})
+    assert count == 6
+    data.intersection_update({1, 2})
+    assert count == 7
+    data.difference_update({1})
+    assert count == 8
+    data.symmetric_difference_update({1, 2})
+    assert count == 9
+    data |= {1, 2, 3}
+    assert count == 10
+    data &= {1, 2}
+    assert count == 11
+    data -= {1}
+    assert count == 12
+    data ^= {1, 2}
+    assert count == 13
+
+
+def test_nested_observables():
+    reset_counter()
+    data = make_observable({
+        'a': 1,
+        'b': [1, 2, 3, {'x': 1, 'y': 2, 'z': 3}],
+        'c': {'x': 1, 'y': 2, 'z': 3, 't': [1, 2, 3]},
+        'd': {1, 2, 3},
+    }, increment_counter)
+    data['a'] = 42
+    assert count == 1
+    data['b'].append(4)
+    assert count == 2
+    data['b'][3].update(t=4)
+    assert count == 3
+    data['c']['x'] = 2
+    assert count == 4
+    data['c']['t'].append(4)
+    assert count == 5
+    data['d'].add(4)
+    assert count == 6

+ 14 - 0
tests/test_select.py

@@ -48,3 +48,17 @@ def test_replace_select(screen: Screen):
     screen.click('Replace')
     screen.click('Replace')
     screen.should_contain('B')
     screen.should_contain('B')
     screen.should_not_contain('A')
     screen.should_not_contain('A')
+
+
+def test_multi_select(screen: Screen):
+    s = ui.select(['Alice', 'Bob', 'Carol'], value='Alice', multiple=True).props('use-chips')
+    ui.label().bind_text_from(s, 'value', backward=str)
+
+    screen.open('/')
+    screen.should_contain("['Alice']")
+    screen.click('Alice')
+    screen.click('Bob')
+    screen.should_contain("['Alice', 'Bob']")
+
+    screen.click('cancel')  # remove icon
+    screen.should_contain("['Bob']")

+ 83 - 0
tests/test_serving_files.py

@@ -0,0 +1,83 @@
+
+from pathlib import Path
+
+import httpx
+import pytest
+
+from nicegui import app, ui
+
+from .screen import PORT, Screen
+from .test_helpers import TEST_DIR
+
+IMAGE_FILE = Path(TEST_DIR).parent / 'examples' / 'slideshow' / 'slides' / 'slide1.jpg'
+VIDEO_FILE = Path(TEST_DIR) / 'media' / 'test.mp4'
+
+
+@pytest.fixture(autouse=True)
+def provide_media_files():
+    if not VIDEO_FILE.exists():
+        VIDEO_FILE.parent.mkdir(exist_ok=True)
+        url = 'https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/360/Big_Buck_Bunny_360_10s_1MB.mp4'
+        with httpx.stream('GET', url) as response:
+            with open(VIDEO_FILE, 'wb') as file:
+                for chunk in response.iter_raw():
+                    file.write(chunk)
+
+
+def assert_video_file_streaming(path: str) -> None:
+    with httpx.Client() as http_client:
+        r = http_client.get(
+            path if 'http' in path else f'http://localhost:{PORT}{path}',
+            headers={'Range': 'bytes=0-1000'},
+        )
+        assert r.status_code == 206
+        assert r.headers['Accept-Ranges'] == 'bytes'
+        assert r.headers['Content-Range'].startswith('bytes 0-1000/')
+        assert r.headers['Content-Length'] == '1001'
+        assert r.headers['Content-Type'] == 'video/mp4'
+
+
+def test_media_files_can_be_streamed(screen: Screen):
+    app.add_media_files('/media', Path(TEST_DIR) / 'media')
+
+    screen.open('/')
+    assert_video_file_streaming('/media/test.mp4')
+
+
+def test_adding_single_media_file(screen: Screen):
+    url_path = app.add_media_file(local_file=VIDEO_FILE)
+
+    screen.open('/')
+    assert_video_file_streaming(url_path)
+
+
+def test_adding_single_static_file(screen: Screen):
+    url_path = app.add_static_file(local_file=IMAGE_FILE)
+
+    screen.open('/')
+    with httpx.Client() as http_client:
+        r = http_client.get(f'http://localhost:{PORT}{url_path}')
+        assert r.status_code == 200
+        assert 'max-age=' in r.headers['Cache-Control']
+
+
+def test_auto_serving_file_from_image_source(screen: Screen):
+    ui.image(IMAGE_FILE)
+
+    screen.open('/')
+    img = screen.find_by_tag('img')
+    assert '/_nicegui/auto/static/' in img.get_attribute('src')
+    assert screen.selenium.execute_script("""
+    return arguments[0].complete && 
+        typeof arguments[0].naturalWidth != "undefined" && 
+        arguments[0].naturalWidth > 0
+    """, img), 'image should load successfully'
+
+
+def test_auto_serving_file_from_video_source(screen: Screen):
+    ui.video(VIDEO_FILE)
+
+    screen.open('/')
+    video = screen.find_by_tag('video')
+    assert '/_nicegui/auto/media/' in video.get_attribute('src')
+    assert_video_file_streaming(video.get_attribute('src'))

+ 134 - 0
tests/test_storage.py

@@ -0,0 +1,134 @@
+import asyncio
+from pathlib import Path
+
+from nicegui import Client, app, background_tasks, ui
+
+from .screen import Screen
+
+
+def test_browser_data_is_stored_in_the_browser(screen: Screen):
+    @ui.page('/')
+    def page():
+        app.storage.browser['count'] = app.storage.browser.get('count', 0) + 1
+        ui.label().bind_text_from(app.storage.browser, 'count')
+
+    @app.get('/count')
+    def count():
+        return 'count = ' + str(app.storage.browser['count'])
+
+    screen.ui_run_kwargs['storage_secret'] = 'just a test'
+    screen.open('/')
+    screen.should_contain('1')
+    screen.open('/')
+    screen.should_contain('2')
+    screen.open('/')
+    screen.should_contain('3')
+    screen.open('/count')
+    screen.should_contain('count = 3')  # also works with FastAPI endpoints
+
+
+def test_browser_storage_supports_asyncio(screen: Screen):
+    @ui.page('/')
+    async def page():
+        app.storage.browser['count'] = app.storage.browser.get('count', 0) + 1
+        await asyncio.sleep(0.5)
+        ui.label(app.storage.browser['count'])
+
+    screen.ui_run_kwargs['storage_secret'] = 'just a test'
+    screen.open('/')
+    screen.switch_to(1)
+    screen.open('/')
+    screen.should_contain('2')
+    screen.switch_to(0)
+    screen.open('/')
+    screen.should_contain('3')
+
+
+def test_browser_storage_modifications_after_page_load_are_forbidden(screen: Screen):
+    @ui.page('/')
+    async def page(client: Client):
+        await client.connected()
+        try:
+            app.storage.browser['test'] = 'data'
+        except TypeError as e:
+            ui.label(str(e))
+
+    screen.ui_run_kwargs['storage_secret'] = 'just a test'
+    screen.open('/')
+    screen.should_contain('response to the browser has already been built')
+
+
+def test_user_storage_modifications(screen: Screen):
+    @ui.page('/')
+    async def page(client: Client, delayed: bool = False):
+        if delayed:
+            await client.connected()
+        app.storage.user['count'] = app.storage.user.get('count', 0) + 1
+        ui.label().bind_text_from(app.storage.user, 'count')
+
+    screen.ui_run_kwargs['storage_secret'] = 'just a test'
+    screen.open('/')
+    screen.should_contain('1')
+    screen.open('/?delayed=True')
+    screen.should_contain('2')
+    screen.open('/')
+    screen.should_contain('3')
+
+
+async def test_access_user_storage_on_interaction(screen: Screen):
+    @ui.page('/')
+    async def page():
+        if 'test_switch' not in app.storage.user:
+            app.storage.user['test_switch'] = False
+        ui.switch('switch').bind_value(app.storage.user, 'test_switch')
+
+    screen.ui_run_kwargs['storage_secret'] = 'just a test'
+    screen.open('/')
+    screen.click('switch')
+    screen.wait(0.5)
+    assert '{"test_switch": true}' in next(Path('.nicegui').glob('storage_user_*.json')).read_text()
+
+
+def test_access_user_storage_from_button_click_handler(screen: Screen):
+    @ui.page('/')
+    async def page():
+        ui.button('test', on_click=app.storage.user.update(inner_function='works'))
+
+    screen.ui_run_kwargs['storage_secret'] = 'just a test'
+    screen.open('/')
+    screen.click('test')
+    screen.wait(1)
+    assert '{"inner_function": "works"}' in next(Path('.nicegui').glob('storage_user_*.json')).read_text()
+
+
+async def test_access_user_storage_from_background_task(screen: Screen):
+    @ui.page('/')
+    def page():
+        async def subtask():
+            await asyncio.sleep(0.1)
+            app.storage.user['subtask'] = 'works'
+        background_tasks.create(subtask())
+
+    screen.ui_run_kwargs['storage_secret'] = 'just a test'
+    screen.open('/')
+    assert '{"subtask": "works"}' in next(Path('.nicegui').glob('storage_user_*.json')).read_text()
+
+
+def test_user_and_general_storage_is_persisted(screen: Screen):
+    @ui.page('/')
+    def page():
+        app.storage.user['count'] = app.storage.user.get('count', 0) + 1
+        app.storage.general['count'] = app.storage.general.get('count', 0) + 1
+        ui.label(f'user: {app.storage.user["count"]}')
+        ui.label(f'general: {app.storage.general["count"]}')
+
+    screen.ui_run_kwargs['storage_secret'] = 'just a test'
+    screen.open('/')
+    screen.open('/')
+    screen.open('/')
+    screen.should_contain('user: 3')
+    screen.should_contain('general: 3')
+    screen.selenium.delete_all_cookies()
+    screen.open('/')
+    screen.should_contain('user: 1')
+    screen.should_contain('general: 4')

+ 20 - 1
tests/test_tabs.py

@@ -3,7 +3,7 @@ from nicegui import ui
 from .screen import Screen
 from .screen import Screen
 
 
 
 
-def test_tabs(screen: Screen):
+def test_with_strings(screen: Screen):
     with ui.tabs() as tabs:
     with ui.tabs() as tabs:
         ui.tab('One')
         ui.tab('One')
         ui.tab('Two')
         ui.tab('Two')
@@ -18,3 +18,22 @@ def test_tabs(screen: Screen):
     screen.should_contain('First tab')
     screen.should_contain('First tab')
     screen.click('Two')
     screen.click('Two')
     screen.should_contain('Second tab')
     screen.should_contain('Second tab')
+
+
+def test_with_tab_objects(screen: Screen):
+    with ui.tabs() as tabs:
+        tab1 = ui.tab('One')
+        tab2 = ui.tab('Two')
+
+    with ui.tab_panels(tabs, value=tab2):
+        with ui.tab_panel(tab1):
+            ui.label('First tab')
+        with ui.tab_panel(tab2):
+            ui.label('Second tab')
+
+    screen.open('/')
+    screen.should_contain('One')
+    screen.should_contain('Two')
+    screen.should_contain('Second tab')
+    screen.click('One')
+    screen.should_contain('First tab')

+ 153 - 0
website/build_search_index.py

@@ -0,0 +1,153 @@
+#!/usr/bin/env python3
+import ast
+import json
+import os
+import re
+from _ast import AsyncFunctionDef
+from pathlib import Path
+from typing import List, Optional, Union
+
+from nicegui import app, ui
+
+dir_path = Path(__file__).parent
+os.chdir(dir_path)
+
+
+def ast_string_node_to_string(node):
+    if isinstance(node, ast.Str):
+        return node.s
+    elif isinstance(node, ast.JoinedStr):
+        return ''.join(ast_string_node_to_string(part) for part in node.values)
+    else:
+        return str(ast.unparse(node))
+
+
+def cleanup(markdown_string: str) -> str:
+    # Remove link URLs but keep the description
+    markdown_string = re.sub(r'\[([^\[]+)\]\([^\)]+\)', r'\1', markdown_string)
+    # Remove inline code ticks
+    markdown_string = re.sub(r'`([^`]+)`', r'\1', markdown_string)
+    # Remove code blocks
+    markdown_string = re.sub(r'```([^`]+)```', r'\1', markdown_string)
+    markdown_string = re.sub(r'``([^`]+)``', r'\1', markdown_string)
+    # Remove braces
+    markdown_string = re.sub(r'\{([^\}]+)\}', r'\1', markdown_string)
+    return markdown_string
+
+
+class DocVisitor(ast.NodeVisitor):
+
+    def __init__(self, topic: Optional[str] = None) -> None:
+        super().__init__()
+        self.topic = topic
+        self.current_title = None
+        self.current_content: List[str] = []
+
+    def visit_Call(self, node: ast.Call):
+        if isinstance(node.func, ast.Name):
+            function_name = node.func.id
+        elif isinstance(node.func, ast.Attribute):
+            function_name = node.func.attr
+        else:
+            raise NotImplementedError(f'Unknown function type: {node.func}')
+        if function_name in ['heading', 'subheading']:
+            self.on_new_heading()
+            self.current_title = node.args[0].s
+        elif function_name == 'markdown':
+            if node.args:
+                raw = ast_string_node_to_string(node.args[0]).splitlines()
+                raw = ' '.join(l.strip() for l in raw).strip()
+                self.current_content.append(cleanup(raw))
+        self.generic_visit(node)
+
+    def on_new_heading(self) -> None:
+        if self.current_title:
+            self.add_to_search_index(self.current_title, self.current_content if self.current_content else 'Overview')
+            self.current_content = []
+
+    def visit_AsyncFunctionDef(self, node: AsyncFunctionDef) -> None:
+        self.visit_FunctionDef(node)
+
+    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
+        if node.name == 'main_demo':
+            docstring = ast.get_docstring(node)
+            if docstring is None:
+                api = getattr(ui, self.topic) if hasattr(ui, self.topic) else getattr(app, self.topic)
+                docstring = api.__doc__ or api.__init__.__doc__
+            lines = cleanup(docstring).splitlines()
+            self.add_to_search_index(lines[0], lines[1:], main=True)
+
+        for decorator in node.decorator_list:
+            if isinstance(decorator, ast.Call):
+                function = decorator.func
+                if isinstance(function, ast.Name) and function.id == 'text_demo':
+                    title = decorator.args[0].s
+                    content = cleanup(decorator.args[1].s).splitlines()
+                    self.add_to_search_index(title, content)
+                if isinstance(function, ast.Name) and function.id == 'element_demo':
+                    attr_name = decorator.args[0].attr
+                    obj_name = decorator.args[0].value.id
+                    if obj_name == 'app':
+                        docstring: str = getattr(app, attr_name).__doc__
+                        docstring = ' '.join(l.strip() for l in docstring.splitlines()).strip()
+                        self.current_content.append(cleanup(docstring))
+                    else:
+                        print(f'Unknown object: {obj_name} for element_demo', flush=True)
+        self.generic_visit(node)
+
+    def add_to_search_index(self, title: str, content: Union[str, list], main: bool = False) -> None:
+        if isinstance(content, list):
+            content_str = ' '.join(l.strip() for l in content).strip()
+        else:
+            content_str = content
+
+        anchor = title.lower().replace(' ', '_')
+        url = f'/documentation/{self.topic or ""}'
+        if not main:
+            url += f'#{anchor}'
+            if self.topic:
+                title = f'{self.topic.replace("_", " ").title()}: {title}'
+        documents.append({
+            'title': title,
+            'content': content_str,
+            'url': url,
+        })
+
+
+class MainVisitor(ast.NodeVisitor):
+
+    def visit_Call(self, node: ast.Call):
+        if isinstance(node.func, ast.Name):
+            function_name = node.func.id
+        elif isinstance(node.func, ast.Attribute):
+            function_name = node.func.attr
+        else:
+            return
+        if function_name == 'example_link':
+            title = ast_string_node_to_string(node.args[0])
+            name = name = title.lower().replace(' ', '_')
+            documents.append({
+                'title': 'Example: ' + title,
+                'content': ast_string_node_to_string(node.args[1]),
+                'url': f'https://github.com/zauberzeug/nicegui/tree/main/examples/{name}/main.py',
+            })
+
+
+def generate_for(file: Path, topic: Optional[str] = None) -> None:
+    tree = ast.parse(file.read_text())
+    doc_visitor = DocVisitor(topic)
+    doc_visitor.visit(tree)
+    if doc_visitor.current_title:
+        doc_visitor.on_new_heading()  # to finalize the last heading
+
+
+documents = []
+tree = ast.parse(Path('../main.py').read_text())
+MainVisitor().visit(tree)
+
+generate_for(Path('./documentation.py'))
+for file in Path('./more_documentation').glob('*.py'):
+    generate_for(file, file.stem.removesuffix('_documentation'))
+
+with open('static/search_index.json', 'w') as f:
+    json.dump(documents, f, indent=2)

+ 9 - 3
website/demo.py

@@ -1,4 +1,5 @@
 import inspect
 import inspect
+import re
 from typing import Callable, Optional, Union
 from typing import Callable, Optional, Union
 
 
 import isort
 import isort
@@ -15,13 +16,18 @@ BROWSER_BGCOLOR = '#00000010'
 BROWSER_COLOR = '#ffffff'
 BROWSER_COLOR = '#ffffff'
 
 
 
 
-def remove_prefix(text: str, prefix: str) -> str:
-    return text[len(prefix):] if text.startswith(prefix) else text
+uncomment_pattern = re.compile(r'^(\s*)# ?')
+
+
+def uncomment(text: str) -> str:
+    """non-executed lines should be shown in the code examples"""
+    return uncomment_pattern.sub(r'\1', text)
 
 
 
 
 def demo(f: Callable) -> Callable:
 def demo(f: Callable) -> Callable:
     with ui.column().classes('w-full items-stretch gap-8 no-wrap min-[1500px]:flex-row'):
     with ui.column().classes('w-full items-stretch gap-8 no-wrap min-[1500px]:flex-row'):
         code = inspect.getsource(f).split('# END OF DEMO')[0].strip().splitlines()
         code = inspect.getsource(f).split('# END OF DEMO')[0].strip().splitlines()
+        code = [line for line in code if not line.endswith("# HIDE")]
         while not code[0].strip().startswith('def') and not code[0].strip().startswith('async def'):
         while not code[0].strip().startswith('def') and not code[0].strip().startswith('async def'):
             del code[0]
             del code[0]
         del code[0]
         del code[0]
@@ -31,7 +37,7 @@ def demo(f: Callable) -> Callable:
             del code[0]
             del code[0]
         indentation = len(code[0]) - len(code[0].lstrip())
         indentation = len(code[0]) - len(code[0].lstrip())
         code = [line[indentation:] for line in code]
         code = [line[indentation:] for line in code]
-        code = ['from nicegui import ui'] + [remove_prefix(line, '# ') for line in code]
+        code = ['from nicegui import ui'] + [uncomment(line) for line in code]
         code = ['' if line == '#' else line for line in code]
         code = ['' if line == '#' else line for line in code]
         if not code[-1].startswith('ui.run('):
         if not code[-1].startswith('ui.run('):
             code.append('')
             code.append('')

+ 59 - 89
website/documentation.py

@@ -166,26 +166,7 @@ def create_full() -> None:
 
 
     load_demo(ui.expansion)
     load_demo(ui.expansion)
     load_demo(ui.splitter)
     load_demo(ui.splitter)
-
-    @text_demo('Tabs', '''
-        The elements `ui.tabs`, `ui.tab`, `ui.tab_panels`, and `ui.tab_panel` resemble
-        [Quasar's tabs](https://quasar.dev/vue-components/tabs)
-        and [tab panels](https://quasar.dev/vue-components/tab-panels) API.
-
-        `ui.tabs` creates a container for the tabs. This could be placed in a `ui.header` for example.
-        `ui.tab_panels` creates a container for the tab panels with the actual content.
-    ''')
-    def tabs_demo():
-        with ui.tabs() as tabs:
-            ui.tab('Home', icon='home')
-            ui.tab('About', icon='info')
-
-        with ui.tab_panels(tabs, value='Home'):
-            with ui.tab_panel('Home'):
-                ui.label('This is the first tab')
-            with ui.tab_panel('About'):
-                ui.label('This is the second tab')
-
+    load_demo('tabs')
     load_demo(ui.menu)
     load_demo(ui.menu)
 
 
     @text_demo('Tooltips', '''
     @text_demo('Tooltips', '''
@@ -195,7 +176,7 @@ def create_full() -> None:
     ''')
     ''')
     def tooltips_demo():
     def tooltips_demo():
         ui.label('Tooltips...').tooltip('...are shown on mouse over')
         ui.label('Tooltips...').tooltip('...are shown on mouse over')
-        with ui.button().props('icon=thumb_up'):
+        with ui.button(icon='thumb_up'):
             ui.tooltip('I like this').classes('bg-green')
             ui.tooltip('I like this').classes('bg-green')
 
 
     load_demo(ui.notify)
     load_demo(ui.notify)
@@ -216,7 +197,7 @@ def create_full() -> None:
     ''')
     ''')
     def design_demo():
     def design_demo():
         ui.radio(['x', 'y', 'z'], value='x').props('inline color=green')
         ui.radio(['x', 'y', 'z'], value='x').props('inline color=green')
-        ui.button().props('icon=touch_app outline round').classes('shadow-lg')
+        ui.button(icon='touch_app').props('outline round').classes('shadow-lg')
         ui.label('Stylish!').style('color: #6E93D6; font-size: 200%; font-weight: 300')
         ui.label('Stylish!').style('color: #6E93D6; font-size: 200%; font-weight: 300')
 
 
     subheading('Try styling NiceGUI elements!')
     subheading('Try styling NiceGUI elements!')
@@ -395,12 +376,12 @@ def create_full() -> None:
     ''')
     ''')
     def page_layout_demo():
     def page_layout_demo():
         @ui.page('/page_layout')
         @ui.page('/page_layout')
-        async def page_layout():
+        def page_layout():
             ui.label('CONTENT')
             ui.label('CONTENT')
             [ui.label(f'Line {i}') for i in range(100)]
             [ui.label(f'Line {i}') for i in range(100)]
             with ui.header(elevated=True).style('background-color: #3874c8').classes('items-center justify-between'):
             with ui.header(elevated=True).style('background-color: #3874c8').classes('items-center justify-between'):
                 ui.label('HEADER')
                 ui.label('HEADER')
-                ui.button(on_click=lambda: right_drawer.toggle()).props('flat color=white icon=menu')
+                ui.button(on_click=lambda: right_drawer.toggle(), icon='menu').props('flat color=white')
             with ui.left_drawer(top_corner=True, bottom_corner=True).style('background-color: #d7e3f4'):
             with ui.left_drawer(top_corner=True, bottom_corner=True).style('background-color: #d7e3f4'):
                 ui.label('LEFT DRAWER')
                 ui.label('LEFT DRAWER')
             with ui.right_drawer(fixed=False).style('background-color: #ebf1fa').props('bordered') as right_drawer:
             with ui.right_drawer(fixed=False).style('background-color: #ebf1fa').props('bordered') as right_drawer:
@@ -413,38 +394,31 @@ def create_full() -> None:
     load_demo(ui.open)
     load_demo(ui.open)
     load_demo(ui.download)
     load_demo(ui.download)
 
 
-    @text_demo('Sessions', '''
-        The optional `request` argument provides insights about the client's URL parameters etc.
-        It also enables you to identify sessions using a [session middleware](https://www.starlette.io/middleware/#sessionmiddleware).
-    ''')
-    def sessions_demo():
-        import uuid
-        from collections import Counter
-        from datetime import datetime
-
-        from starlette.middleware.sessions import SessionMiddleware
-        from starlette.requests import Request
-
-        from nicegui import app
+    load_demo('storage')
 
 
-        # app.add_middleware(SessionMiddleware, secret_key='some_random_string')
-
-        counter = Counter()
-        start = datetime.now().strftime('%H:%M, %d %B %Y')
-
-        @ui.page('/session_demo')
-        def session_demo(request: Request):
-            if 'id' not in request.session:
-                request.session['id'] = str(uuid.uuid4())
-            counter[request.session['id']] += 1
-            ui.label(f'{len(counter)} unique views ({sum(counter.values())} overall) since {start}')
-
-        ui.link('Visit session demo', session_demo)
+    @text_demo('Parameter injection', '''
+        Thanks to FastAPI, a page function accepts optional parameters to provide
+        [path parameters](https://fastapi.tiangolo.com/tutorial/path-params/), 
+        [query parameters](https://fastapi.tiangolo.com/tutorial/query-params/) or the whole incoming
+        [request](https://fastapi.tiangolo.com/advanced/using-request-directly/) for accessing
+        the body payload, headers, cookies and more.
+    ''')
+    def parameter_demo():
+        @ui.page('/icon/{icon}')
+        def icons(icon: str, amount: int = 1):
+            ui.label(icon).classes('text-h3')
+            with ui.row():
+                [ui.icon(icon).classes('text-h3') for _ in range(amount)]
+        ui.link('Star', '/icon/star?amount=5')
+        ui.link('Home', '/icon/home')
+        ui.link('Water', '/icon/water_drop?amount=3')
 
 
     load_demo(ui.run_javascript)
     load_demo(ui.run_javascript)
 
 
     heading('Routes')
     heading('Routes')
 
 
+    subheading('Static files')
+
     @element_demo(app.add_static_files)
     @element_demo(app.add_static_files)
     def add_static_files_demo():
     def add_static_files_demo():
         from nicegui import app
         from nicegui import app
@@ -455,6 +429,25 @@ def create_full() -> None:
         ui.link('Custom FastAPI app', '/examples/fastapi/main.py')
         ui.link('Custom FastAPI app', '/examples/fastapi/main.py')
         ui.link('Authentication', '/examples/authentication/main.py')
         ui.link('Authentication', '/examples/authentication/main.py')
 
 
+    subheading('Media files')
+
+    @element_demo(app.add_media_files)
+    def add_media_files_demo():
+        from pathlib import Path
+
+        import requests
+
+        from nicegui import app
+
+        media = Path('media')
+        # media.mkdir(exist_ok=True)
+        # r = requests.get('https://cdn.coverr.co/videos/coverr-cloudy-sky-2765/1080p.mp4')
+        # (media  / 'clouds.mp4').write_bytes(r.content)
+        # app.add_media_files('/my_videos', media)
+        # ui.video('/my_videos/clouds.mp4')
+        # END OF DEMO
+        ui.video('https://cdn.coverr.co/videos/coverr-cloudy-sky-2765/1080p.mp4')
+
     @text_demo('API Responses', '''
     @text_demo('API Responses', '''
         NiceGUI is based on [FastAPI](https://fastapi.tiangolo.com/).
         NiceGUI is based on [FastAPI](https://fastapi.tiangolo.com/).
         This means you can use all of FastAPI's features.
         This means you can use all of FastAPI's features.
@@ -509,6 +502,8 @@ def create_full() -> None:
         global dt
         global dt
         dt = datetime.now()
         dt = datetime.now()
 
 
+    subheading('Shutdown')
+
     @element_demo(app.shutdown)
     @element_demo(app.shutdown)
     def shutdown_demo():
     def shutdown_demo():
         from nicegui import app
         from nicegui import app
@@ -541,38 +536,7 @@ def create_full() -> None:
             ui.button('Add label', on_click=lambda: ui.label('Click!'))
             ui.button('Add label', on_click=lambda: ui.label('Click!'))
             ui.timer(1.0, lambda: ui.label('Tick!'), once=True)
             ui.timer(1.0, lambda: ui.label('Tick!'), once=True)
 
 
-    @text_demo('Generic Events', '''
-        Most UI elements come with predefined events.
-        For example, a `ui.button` like "A" in the demo has an `on_click` parameter that expects a coroutine or function.
-        But you can also use the `on` method to register a generic event handler like for "B".
-        This allows you to register handlers for any event that is supported by JavaScript and Quasar.
-
-        For example, you can register a handler for the `mousemove` event like for "C", even though there is no `on_mousemove` parameter for `ui.button`.
-        Some events, like `mousemove`, are fired very often.
-        To avoid performance issues, you can use the `throttle` parameter to only call the handler every `throttle` seconds ("D").
-
-        The generic event handler can be synchronous or asynchronous and optionally takes an event dictionary as argument ("E").
-        You can also specify which attributes of the JavaScript or Quasar event should be passed to the handler ("F").
-        This can reduce the amount of data that needs to be transferred between the server and the client.
-
-        You can also include [key modifiers](https://vuejs.org/guide/essentials/event-handling.html#key-modifiers) ("G"),
-        modifier combinations ("H"),
-        and [event modifiers](https://vuejs.org/guide/essentials/event-handling.html#mouse-button-modifiers) ("I").
-    ''')
-    def generic_events_demo():
-        with ui.row():
-            ui.button('A', on_click=lambda: ui.notify('You clicked the button A.'))
-            ui.button('B').on('click', lambda: ui.notify('You clicked the button B.'))
-        with ui.row():
-            ui.button('C').on('mousemove', lambda: ui.notify('You moved on button C.'))
-            ui.button('D').on('mousemove', lambda: ui.notify('You moved on button D.'), throttle=0.5)
-        with ui.row():
-            ui.button('E').on('mousedown', lambda e: ui.notify(str(e)))
-            ui.button('F').on('mousedown', lambda e: ui.notify(str(e)), ['ctrlKey', 'shiftKey'])
-        with ui.row():
-            ui.input('G').classes('w-12').on('keydown.space', lambda: ui.notify('You pressed space.'))
-            ui.input('H').classes('w-12').on('keydown.y.shift', lambda: ui.notify('You pressed Shift+Y'))
-            ui.input('I').classes('w-12').on('keydown.once', lambda: ui.notify('You started typing.'))
+    load_demo('generic_events')
 
 
     heading('Configuration')
     heading('Configuration')
 
 
@@ -583,22 +547,28 @@ def create_full() -> None:
     demo.BROWSER_BGCOLOR = '#ffffff'
     demo.BROWSER_BGCOLOR = '#ffffff'
 
 
     @text_demo('Native Mode', '''
     @text_demo('Native Mode', '''
-        You can enable native mode for NiceGUI by specifying `native=True` in the `ui.run` function. 
-        To customize the initial window size and display mode, use the `window_size` and `fullscreen` parameters respectively. 
+        You can enable native mode for NiceGUI by specifying `native=True` in the `ui.run` function.
+        To customize the initial window size and display mode, use the `window_size` and `fullscreen` parameters respectively.
         Additionally, you can provide extra keyword arguments via `app.native.window_args` and `app.native.start_args`.
         Additionally, you can provide extra keyword arguments via `app.native.window_args` and `app.native.start_args`.
-        Pick any parameter as it is defined by the internally used [pywebview module](https://pywebview.flowrl.com/guide/api.html) 
+        Pick any parameter as it is defined by the internally used [pywebview module](https://pywebview.flowrl.com/guide/api.html)
         for the `webview.create_window` and `webview.start` functions.
         for the `webview.create_window` and `webview.start` functions.
-        Note that these keyword arguments will take precedence over the parameters defined in ui.run.
+        Note that these keyword arguments will take precedence over the parameters defined in `ui.run`.
+
+        In native mode the `app.native.main_window` object allows you to access the underlying window.
+        It is an async version of [`Window` from pywebview](https://pywebview.flowrl.com/guide/api.html#window-object).
     ''', tab=lambda: ui.label('NiceGUI'))
     ''', tab=lambda: ui.label('NiceGUI'))
     def native_mode_demo():
     def native_mode_demo():
         from nicegui import app
         from nicegui import app
 
 
-        ui.label('app running in native mode')
-
         app.native.window_args['resizable'] = False
         app.native.window_args['resizable'] = False
         app.native.start_args['debug'] = True
         app.native.start_args['debug'] = True
 
 
+        ui.label('app running in native mode')
+        # ui.button('enlarge', on_click=lambda: app.native.main_window.resize(1000, 700))
+        #
         # ui.run(native=True, window_size=(400, 300), fullscreen=False)
         # ui.run(native=True, window_size=(400, 300), fullscreen=False)
+        # END OF DEMO
+        ui.button('enlarge', on_click=lambda: ui.notify('window will be set to 1000x700 in native mode'))
     # HACK: restore color
     # HACK: restore color
     demo.BROWSER_BGCOLOR = demo_BROWSER_BGCOLOR
     demo.BROWSER_BGCOLOR = demo_BROWSER_BGCOLOR
 
 
@@ -707,7 +677,7 @@ def create_full() -> None:
                     '--name', 'myapp', # name of your app
                     '--name', 'myapp', # name of your app
                     '--onefile',
                     '--onefile',
                     #'--windowed', # prevent console appearing, only use with ui.run(native=True, ...)
                     #'--windowed', # prevent console appearing, only use with ui.run(native=True, ...)
-                    '--add-data', f'{Path(nicegui.__file__).parent}{os.pathsep}nicegui'       
+                    '--add-data', f'{Path(nicegui.__file__).parent}{os.pathsep}nicegui'
                 ]
                 ]
                 subprocess.call(cmd)
                 subprocess.call(cmd)
                 ```
                 ```

+ 5 - 3
website/documentation_tools.py

@@ -27,6 +27,7 @@ def get_menu() -> ui.left_drawer:
 
 
 
 
 def heading(text: str, *, make_menu_entry: bool = True) -> None:
 def heading(text: str, *, make_menu_entry: bool = True) -> None:
+    ui.link_target(create_anchor_name(text))
     ui.html(f'<em>{text}</em>').classes('mt-8 text-3xl font-weight-500')
     ui.html(f'<em>{text}</em>').classes('mt-8 text-3xl font-weight-500')
     if make_menu_entry:
     if make_menu_entry:
         with get_menu():
         with get_menu():
@@ -93,10 +94,11 @@ class element_demo:
         self.element_class = element_class
         self.element_class = element_class
 
 
     def __call__(self, f: Callable, *, more_link: Optional[str] = None) -> Callable:
     def __call__(self, f: Callable, *, more_link: Optional[str] = None) -> Callable:
-        doc = self.element_class.__doc__ or self.element_class.__init__.__doc__
+        doc = f.__doc__ or self.element_class.__doc__ or self.element_class.__init__.__doc__
         title, documentation = doc.split('\n', 1)
         title, documentation = doc.split('\n', 1)
         with ui.column().classes('w-full mb-8 gap-2'):
         with ui.column().classes('w-full mb-8 gap-2'):
-            subheading(title, more_link=more_link)
+            if more_link:
+                subheading(title, more_link=more_link)
             render_docstring(documentation, with_params=more_link is None)
             render_docstring(documentation, with_params=more_link is None)
             result = demo(f)
             result = demo(f)
             if more_link:
             if more_link:
@@ -105,7 +107,7 @@ class element_demo:
 
 
 
 
 def load_demo(api: Union[type, Callable, str]) -> None:
 def load_demo(api: Union[type, Callable, str]) -> None:
-    name = pascal_to_snake(api if isinstance(api, str) else api.__name__)
+    name = api if isinstance(api, str) else pascal_to_snake(api.__name__)
     try:
     try:
         module = importlib.import_module(f'website.more_documentation.{name}_documentation')
         module = importlib.import_module(f'website.more_documentation.{name}_documentation')
     except ModuleNotFoundError:
     except ModuleNotFoundError:

+ 2 - 2
website/more_documentation/audio_documentation.py

@@ -5,5 +5,5 @@ def main_demo() -> None:
     a = ui.audio('https://cdn.pixabay.com/download/audio/2022/02/22/audio_d1718ab41b.mp3')
     a = ui.audio('https://cdn.pixabay.com/download/audio/2022/02/22/audio_d1718ab41b.mp3')
     a.on('ended', lambda _: ui.notify('Audio playback completed'))
     a.on('ended', lambda _: ui.notify('Audio playback completed'))
 
 
-    ui.button(on_click=lambda: a.props('muted')).props('outline icon=volume_off')
-    ui.button(on_click=lambda: a.props(remove='muted')).props('outline icon=volume_up')
+    ui.button(on_click=lambda: a.props('muted'), icon='volume_off').props('outline')
+    ui.button(on_click=lambda: a.props(remove='muted'), icon='volume_up').props('outline')

Vissa filer visades inte eftersom för många filer har ändrats