Jelajahi Sumber

Merge branch 'main' into input-error

Falko Schindler 1 tahun lalu
induk
melakukan
0f5a8a89df
100 mengubah file dengan 2346 tambahan dan 754 penghapusan
  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"]
       fail-fast: false
     runs-on: ubuntu-latest
-    timeout-minutes: 20
+    timeout-minutes: 40
     steps:
       - uses: actions/checkout@v3
       - name: set up Python
@@ -26,6 +26,7 @@ jobs:
           poetry install
           # install packages to run the examples
           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
           pip install importlib-resources
       - name: test startup

+ 2 - 2
.gitignore

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

+ 3 - 3
CITATION.cff

@@ -8,7 +8,7 @@ authors:
   given-names: Rodja
   orcid: https://orcid.org/0009-0009-4735-6227
 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
-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
 - notifications, dialogs and menus to provide state of the art user interaction
 - shared and individual web pages
+- easy-to-use per-user and general persistence
 - ability to add custom routes and data responses
 - capture keyboard input for global shortcuts etc.
 - customize look by defining primary, secondary and accent colors

+ 15 - 44
examples/authentication/main.py

@@ -1,71 +1,42 @@
 #!/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
-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.
-'''
-
-import os
-import uuid
-from typing import Dict
-
-from fastapi import Request
+"""
 from fastapi.responses import RedirectResponse
-from starlette.middleware.sessions import SessionMiddleware
 
 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('/')
-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')
-    session = session_info[request.session['id']]
     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')
-def login(request: Request) -> None:
+def login() -> None:
     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('/')
         else:
             ui.notify('Wrong username or password', color='negative')
 
-    if is_authenticated(request):
+    if app.storage.user.get('authenticated', False):
         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'):
         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.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 nicegui import ui
+from nicegui import app, ui
 
 
-def init(app: FastAPI) -> None:
+def init(fastapi_app: FastAPI) -> None:
     @ui.page('/show')
     def show():
         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 typing import Dict, Optional
 
@@ -27,6 +28,7 @@ class local_file_picker(ui.dialog):
         self.show_hidden_files = show_hidden_files
 
         with self, ui.card():
+            self.add_drives_toggle()
             self.grid = ui.aggrid({
                 'columnDefs': [{'field': 'name', 'headerName': 'File'}],
                 'rowSelection': 'multiple' if multiple else 'single',
@@ -36,6 +38,16 @@ class local_file_picker(ui.dialog):
                 ui.button('Ok', on_click=self._handle_ok)
         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:
         paths = list(self.path.glob('*'))
         if not self.show_hidden_files:
@@ -58,7 +70,7 @@ class local_file_picker(ui.dialog):
             })
         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'])
         if self.path.is_dir():
             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)
     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()

+ 13 - 24
examples/menu_and_tabs/main.py

@@ -1,24 +1,12 @@
 #!/usr/bin/env python3
-from typing import Dict
-
 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:
     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:
     ui.label('Footer')
@@ -27,13 +15,14 @@ with ui.left_drawer().classes('bg-blue-100') as left_drawer:
     ui.label('Side menu')
 
 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()

+ 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:
 
     @ui.page('/a')
-    def example_page():
+    def example_page_a():
         with theme.frame('- Example A -'):
             ui.label('Example A').classes('text-h4 text-grey-8')
 
     @ui.page('/b')
-    def example_page():
+    def example_page_b():
         with theme.frame('- Example B -'):
             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
+import example_c
 import example_pages
 import home_page
 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
@@ -16,4 +17,7 @@ def index_page() -> None:
 # this call shows that you can also move the whole page creation into a separate file
 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')

+ 1 - 1
examples/modularization/theme.py

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

+ 3 - 3
examples/script_executor/main.py

@@ -4,11 +4,11 @@ import os.path
 import platform
 import shlex
 
-from nicegui import background_tasks, ui
+from nicegui import ui
 
 
 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()
     result.content = ''
     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']
 with ui.row():
     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)

+ 6 - 5
examples/single_page_app/main.py

@@ -4,21 +4,21 @@ from router import Router
 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
-async def main():
+def main():
     router = Router()
 
     @router.add('/')
-    async def show_one():
+    def show_one():
         ui.label('Content One').classes('text-2xl')
 
     @router.add('/two')
-    async def show_two():
+    def show_two():
         ui.label('Content Two').classes('text-2xl')
 
     @router.add('/three')
-    async def show_three():
+    def show_three():
         ui.label('Content Three').classes('text-2xl')
 
     # 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
     router.frame().classes('w-full p-4 bg-gray-100')
 
+
 ui.run()

+ 2 - 2
examples/sqlite_database/main.py

@@ -24,8 +24,8 @@ def users_ui() -> None:
                 ui.label(user['name'])
                 ui.label(user['age'])
             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:

+ 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}),
                     new_name.set_value(None),
                     new_age.set_value(None),
-                )).props('flat fab-mini icon=add')
+                ), icon='add').props('flat fab-mini')
             with table.cell():
                 new_name = ui.input('Name')
             with table.cell():

+ 32 - 40
examples/todo_list/main.py

@@ -1,6 +1,6 @@
 #!/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
 
@@ -11,55 +11,47 @@ class TodoItem:
     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
-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
-    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'):
-        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'):
-            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'):
-    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.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()

+ 5 - 3
fetch_tailwind.py

@@ -94,7 +94,7 @@ for property in properties:
 with open(Path(__file__).parent / 'nicegui' / 'tailwind.py', 'w') as f:
     f.write('from __future__ import annotations\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('if TYPE_CHECKING:\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('\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('    @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('    @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('    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('            args[0].apply(self.element)\n')
     f.write('        else:\n')

+ 1 - 1
fly.toml

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

+ 17 - 12
main.py

@@ -23,6 +23,7 @@ from nicegui import ui
 from website import documentation, example_card, svg
 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.search import Search
 from website.star import add_star
 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('/fonts', str(Path(__file__).parent / 'website' / 'fonts'))
+app.add_static_files('/static', str(Path(__file__).parent / 'website' / 'static'))
 
 
 @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') \
             .style('box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1)'):
         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'):
-            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')
         with ui.row().classes('max-lg:hidden'):
             for title, target in menu_items.items():
                 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')
-        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')
         with ui.link(target='https://github.com/zauberzeug/nicegui/').tooltip('GitHub'):
             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.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'):
                     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('/')
@@ -205,7 +209,7 @@ async def index_page(client: Client) -> None:
             features('insights', 'Visualization', [
                 'charts, diagrams and tables',
                 '3D scenes',
-                'progress bars',
+                'straight-forward data binding',
                 'built-in timer for data refresh',
             ])
             features('brush', 'Styling', [
@@ -217,7 +221,7 @@ async def index_page(client: Client) -> None:
             features('source', 'Coding', [
                 'routing for multiple pages',
                 'auto-reload on code change',
-                'straight-forward data binding',
+                'persistent user sessions',
                 'Jupyter notebook compatibility',
             ])
             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('SQLite Database', 'CRUD operations on a SQLite database')
             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'):
         link_target('why')
@@ -335,8 +340,8 @@ def documentation_page() -> None:
 
 @ui.page('/documentation/{name}')
 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')
     more = getattr(module, 'more', None)
     if hasattr(ui, name):
@@ -351,7 +356,7 @@ async def documentation_page_more(name: str, client: Client) -> None:
     with side_menu() as menu:
         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'):
-        section_heading('Documentation', f'ui.*{name}*')
+        section_heading('Documentation', f'ui.*{name}*' if hasattr(ui, name) else f'*{name.replace("_", " ").title()}*')
         with menu:
             ui.markdown('**Demos**' if more else '**Demo**').classes('mt-4')
         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:
     import importlib_metadata
 
-__version__ = importlib_metadata.version('nicegui')
+__version__: str = importlib_metadata.version('nicegui')
 
 from . import elements, globals, ui
+from .api_router import APIRouter
 from .client import Client
 from .nicegui import app
 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 . import globals
+from . import globals, helpers
 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):
@@ -12,6 +20,7 @@ class App(FastAPI):
     def __init__(self, **kwargs) -> None:
         super().__init__(**kwargs)
         self.native = Native()
+        self.storage = Storage()
 
     def on_connect(self, handler: Union[Callable, Awaitable]) -> None:
         """Called every time a new client connects to NiceGUI.
@@ -43,7 +52,7 @@ class App(FastAPI):
         """
         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.
 
         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')
         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'`.
         This is useful for providing local data like images to the frontend.
         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.
 
+        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 local_directory: local folder with files to serve as static content
         """
         if url_path == '/':
             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:
         """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.
     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)
     running_tasks.add(task)
     task.add_done_callback(running_tasks.discard)

+ 22 - 12
nicegui/binding.py

@@ -2,6 +2,7 @@ import asyncio
 import logging
 import time
 from collections import defaultdict
+from collections.abc import Mapping
 from typing import Any, Callable, DefaultDict, Dict, List, Optional, Set, Tuple, Type, Union
 
 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]]] = []
 
 
-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]
     else:
         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):
         obj[name] = value
     else:
         setattr(obj, name, value)
 
 
-async def loop():
+async def loop() -> None:
     while True:
         visited: Set[Tuple[int, str]] = set()
         t = time.time()
         for link in active_links:
             (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
         if time.time() - t > 0.01:
             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), []):
         if (id(target_obj), target_name) in visited:
             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:

+ 2 - 2
nicegui/client.py

@@ -40,7 +40,7 @@ class Client:
                 with Element('q-page'):
                     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.body_html = ''
@@ -108,7 +108,7 @@ class Client:
         self.is_waiting_for_disconnect = False
 
     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.
 
         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 . import binding, events, globals, outbox
+from . import binding, events, globals, outbox, storage
 from .elements.mixins.visibility import Visibility
 from .event_listener import EventListener
 from .slot import Slot
@@ -80,7 +80,7 @@ class Element(Visibility):
             for child in slot:
                 yield child
 
-    def _collect_slot_dict(self) -> Dict[str, List[int]]:
+    def _collect_slot_dict(self) -> Dict[str, Any]:
         return {
             name: {'template': slot.template, 'ids': [child.id for child in slot]}
             for name, slot in self.slots.items()
@@ -230,6 +230,7 @@ class Element(Visibility):
                 throttle=throttle,
                 leading_events=leading_events,
                 trailing_events=trailing_events,
+                request=storage.request_contextvar.get(),
             )
             self._event_listeners[listener.id] = listener
             self.update()
@@ -237,6 +238,7 @@ class Element(Visibility):
 
     def _handle_event(self, msg: Dict) -> None:
         listener = self._event_listeners[msg['listener_id']]
+        storage.request_contextvar.set(listener.request)
         events.handle_event(listener.handler, msg, sender=self)
 
     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_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.parent.update()
         target_container = target_container or self.parent_slot.parent

+ 3 - 2
nicegui/elements/aggrid.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from typing import Dict, List, Optional
+from typing import Dict, List, Optional, cast
 
 from ..dependencies import register_component
 from ..element import Element
@@ -67,7 +67,8 @@ class AgGrid(Element):
 
         :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]:
         """Get the single currently selected row.

+ 7 - 2
nicegui/elements/audio.py

@@ -1,5 +1,8 @@
 import warnings
+from pathlib import Path
+from typing import Union
 
+from .. import globals
 from ..dependencies import register_component
 from ..element import Element
 
@@ -8,7 +11,7 @@ register_component('audio', __file__, 'audio.js')
 
 class Audio(Element):
 
-    def __init__(self, src: str, *,
+    def __init__(self, src: Union[str, Path], *,
                  controls: bool = True,
                  autoplay: bool = False,
                  muted: bool = False,
@@ -17,7 +20,7 @@ class Audio(Element):
                  ) -> None:
         """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 autoplay: whether to start playing the audio automatically (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()`.
         """
         super().__init__('audio')
+        if Path(src).is_file():
+            src = globals.app.add_media_file(local_file=src)
         self._props['src'] = src
         self._props['controls'] = controls
         self._props['autoplay'] = autoplay

+ 4 - 7
nicegui/elements/avatar.py

@@ -1,10 +1,10 @@
 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,
                  icon: Optional[str] = None, *,
@@ -28,16 +28,13 @@ class Avatar(Element):
         :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)
         """
-        super().__init__('q-avatar')
+        super().__init__(tag='q-avatar', background_color=color, text_color=text_color)
 
         if icon is not None:
             self._props['icon'] = icon
         self._props['square'] = square
         self._props['rounded'] = rounded
 
-        set_background_color(self, color)
-        set_text_color(self, text_color, prop_name='text-color')
-
         if size is not None:
             self._props['size'] = size
 

+ 4 - 5
nicegui/elements/badge.py

@@ -1,10 +1,11 @@
 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
 
 
-class Badge(TextElement):
+class Badge(TextElement, BackgroundColorElement, TextColorElement):
+    TEXT_COLOR_PROP = 'text-color'
 
     def __init__(self,
                  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 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

+ 8 - 4
nicegui/elements/button.py

@@ -1,18 +1,19 @@
 import asyncio
 from typing import Any, Callable, Optional
 
-from ..colors import set_background_color
 from ..events import ClickEventArguments, handle_event
+from .mixins.color_elements import BackgroundColorElement
 from .mixins.disableable_element import DisableableElement
 from .mixins.text_element import TextElement
 
 
-class Button(TextElement, DisableableElement):
+class Button(TextElement, DisableableElement, BackgroundColorElement):
 
     def __init__(self,
                  text: str = '', *,
                  on_click: Optional[Callable[..., Any]] = None,
                  color: Optional[str] = 'primary',
+                 icon: Optional[str] = None,
                  ) -> None:
         """Button
 
@@ -26,9 +27,12 @@ class Button(TextElement, DisableableElement):
         :param text: the label of the button
         :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 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:
             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'):
             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:
         """Open the color picker"""

+ 3 - 6
nicegui/elements/icon.py

@@ -1,10 +1,9 @@
 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,
                  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 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
 
         if 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
 
 
 class Image(SourceElement):
 
-    def __init__(self, source: str = '') -> None:
+    def __init__(self, source: Union[str, Path] = '') -> None:
         """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)

+ 2 - 1
nicegui/elements/input.py

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

+ 4 - 3
nicegui/elements/interactive_image.py

@@ -1,6 +1,7 @@
 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 ..events import MouseEventArguments, handle_event
@@ -14,7 +15,7 @@ class InteractiveImage(SourceElement, ContentElement):
     CONTENT_PROP = 'content'
 
     def __init__(self,
-                 source: str = '', *,
+                 source: Union[str, Path] = '', *,
                  content: str = '',
                  on_mouse: Optional[Callable[..., Any]] = None,
                  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.
         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 on_mouse: callback for mouse events (yields `type`, `image_x` and `image_y`)
         :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 ..colors import set_text_color
 from .label import Label
+from .mixins.color_elements import TextColorElement
 from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
 
-class Knob(ValueElement, DisableableElement):
+class Knob(ValueElement, DisableableElement, TextColorElement):
 
     def __init__(self,
                  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 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['max'] = max
         self._props['step'] = step
-        set_text_color(self, color)
         self._props['show-value'] = True  # NOTE: enable default slot, e.g. for nested icon
 
         if center_color:

+ 12 - 3
nicegui/elements/link.py

@@ -10,7 +10,11 @@ register_component('link', __file__, 'link.js')
 
 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
 
         Create a hyperlink.
@@ -19,11 +23,16 @@ class Link(TextElement):
         and link to it with `ui.link(target="#name")`.
 
         :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)
         """
         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._classes = ['nicegui-link']
 

+ 5 - 1
nicegui/elements/log.js

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

+ 5 - 2
nicegui/elements/log.py

@@ -22,11 +22,14 @@ class Log(Element):
         self._props['lines'] = ''
         self._classes = ['nicegui-log']
         self.lines: deque[str] = deque(maxlen=max_lines)
+        self.total_count: int = 0
 
     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.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:
         """Clear the log"""

+ 1 - 1
nicegui/elements/mermaid.py

@@ -19,4 +19,4 @@ class Mermaid(ContentElement):
 
     def on_content_change(self, content: str) -> None:
         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:
         super().__init__(**kwargs)
         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:
         """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 ... import globals
 from ...binding import BindableProperty, bind, bind_from, bind_to
 from ...element import Element
+from ...helpers import is_file
 
 
 class SourceElement(Element):
     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)
+        if is_file(source):
+            source = globals.app.add_static_file(local_file=source)
         self.source = source
         self._props['src'] = source
 
@@ -64,14 +69,14 @@ class SourceElement(Element):
         bind(self, 'source', target_object, target_name, forward=forward, backward=backward)
         return self
 
-    def set_source(self, source: str) -> None:
+    def set_source(self, source: Union[str, Path]) -> None:
         """Set the source of this element.
 
         :param source: The new 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.
 
         :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
 
@@ -8,9 +8,9 @@ from ...events import ValueChangeEventArguments, handle_event
 
 
 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))
 
     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
 
@@ -14,6 +14,12 @@ class Visibility:
     def __init__(self, **kwargs: Any) -> None:
         super().__init__(**kwargs)
         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,
                            target_object: Any,
@@ -79,11 +85,12 @@ class Visibility:
         """
         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.
 
         :param visible: Whether the element should be visible.
         """
+        self = cast('Element', self)
         if visible and 'hidden' in self._classes:
             self._classes.remove('hidden')
             self.update()

+ 5 - 7
nicegui/elements/progress.py

@@ -2,11 +2,11 @@ from typing import Optional
 
 from nicegui import ui
 
-from ..colors import set_text_color
+from .mixins.color_elements import TextColorElement
 from .mixins.value_element import ValueElement
 
 
-class LinearProgress(ValueElement):
+class LinearProgress(ValueElement, TextColorElement):
     VALUE_PROP = 'value'
 
     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 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'
-        set_text_color(self, color)
 
         if show_value:
             with self:
                 ui.label().classes('absolute-center text-sm text-white').bind_text_from(self, 'value')
 
 
-class CircularProgress(ValueElement):
+class CircularProgress(ValueElement, TextColorElement):
     VALUE_PROP = 'value'
 
     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 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['max'] = max
         self._props['size'] = size
         self._props['show-value'] = show_value
-        set_text_color(self, color)
         self._props['track-color'] = 'grey-4'
 
         if show_value:

+ 4 - 4
nicegui/elements/scene_object3d.py

@@ -1,12 +1,12 @@
 import uuid
-from typing import TYPE_CHECKING, Any, List, Optional
+from typing import TYPE_CHECKING, Any, List, Optional, Union, cast
 
 import numpy as np
 
 from .. import globals
 
 if TYPE_CHECKING:
-    from .scene import Scene
+    from .scene import Scene, SceneObject
 
 
 class Object3D:
@@ -15,9 +15,9 @@ class Object3D:
         self.type = type
         self.id = str(uuid.uuid4())
         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.parent: Object3D = self.scene.stack[-1]
+        self.parent: Union[Object3D, SceneObject] = self.scene.stack[-1]
         self.args: List = list(args)
         self.color: str = '#ffffff'
         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):
 
-    def __init__(self, options: Union[List, Dict], *,
+    def __init__(self,
+                 options: Union[List, Dict], *,
                  label: Optional[str] = None,
                  value: Any = None,
                  on_change: Optional[Callable[..., Any]] = None,
                  with_input: bool = False,
+                 multiple: bool = False,
                  ) -> None:
         """Dropdown Selection
 
@@ -27,7 +29,15 @@ class Select(ChoiceElement, DisableableElement):
         :param value: the initial value
         :param on_change: callback to execute when selection changes
         :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)
         if label is not None:
             self._props['label'] = label
@@ -37,6 +47,7 @@ class Select(ChoiceElement, DisableableElement):
             self._props['hide-selected'] = True
             self._props['fill-input'] = True
             self._props['input-debounce'] = 0
+        self._props['multiple'] = multiple
 
     def on_filter(self, event: Dict) -> None:
         self.options = [
@@ -47,11 +58,24 @@ class Select(ChoiceElement, DisableableElement):
         self.update()
 
     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:
-        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 ..colors import set_text_color
-from ..element import Element
+from .mixins.color_elements import TextColorElement
 
 SpinnerTypes = Literal[
     'default',
@@ -32,7 +31,7 @@ SpinnerTypes = Literal[
 ]
 
 
-class Spinner(Element):
+class Spinner(TextColorElement):
 
     def __init__(self,
                  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 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
-        set_text_color(self, color)
         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 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')
 

+ 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 .mixins.disableable_element import DisableableElement
@@ -8,7 +10,7 @@ from .mixins.value_element import ValueElement
 class Tabs(ValueElement):
 
     def __init__(self, *,
-                 value: Any = None,
+                 value: Union[Tab, TabPanel, None] = None,
                  on_change: Optional[Callable[..., Any]] = None,
                  ) -> None:
         """Tabs
@@ -16,11 +18,13 @@ class Tabs(ValueElement):
         This element represents `Quasar's QTabs <https://quasar.dev/vue-components/tabs#qtabs-api>`_ component.
         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
         """
         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):
@@ -29,9 +33,9 @@ class Tab(DisableableElement):
         """Tab
 
         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 icon: icon of the tab (default: `None`)
         """
@@ -47,34 +51,44 @@ class TabPanels(ValueElement):
 
     def __init__(self,
                  tabs: Tabs, *,
-                 value: Any = None,
+                 value: Union[Tab, TabPanel, None] = None,
                  on_change: Optional[Callable[..., Any]] = None,
                  animated: bool = True,
+                 keep_alive: bool = True,
                  ) -> None:
         """Tab Panels
 
         This element represents `Quasar's QTabPanels <https://quasar.dev/vue-components/tab-panels#qtabpanels-api>`_ component.
         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 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)
         tabs.bind_value(self, 'value')
         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):
 
-    def __init__(self, name: str) -> None:
+    def __init__(self, name: Union[Tab, str]) -> None:
         """Tab Panel
 
         This element represents `Quasar's QTabPanel <https://quasar.dev/vue-components/tab-panels#qtabpanel-api>`_ component.
         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')
-        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 ..events import EventArguments, UploadEventArguments, handle_event
@@ -51,14 +52,15 @@ class Upload(DisableableElement):
             self._props['max-files'] = max_files
 
         @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():
+                assert isinstance(data, UploadFile)
                 args = UploadEventArguments(
                     sender=self,
                     client=self.client,
                     content=data.file,
-                    name=data.filename,
-                    type=data.content_type,
+                    name=data.filename or '',
+                    type=data.content_type or '',
                 )
                 handle_event(on_upload, args)
             return {'upload': 'success'}

+ 7 - 2
nicegui/elements/video.py

@@ -1,5 +1,8 @@
 import warnings
+from pathlib import Path
+from typing import Union
 
+from .. import globals
 from ..dependencies import register_component
 from ..element import Element
 
@@ -8,7 +11,7 @@ register_component('video', __file__, 'video.js')
 
 class Video(Element):
 
-    def __init__(self, src: str, *,
+    def __init__(self, src: Union[str, Path], *,
                  controls: bool = True,
                  autoplay: bool = False,
                  muted: bool = False,
@@ -17,7 +20,7 @@ class Video(Element):
                  ) -> None:
         """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 autoplay: whether to start playing the video automatically (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()`.
         """
         super().__init__('video')
+        if Path(src).is_file():
+            src = globals.app.add_media_file(local_file=src)
         self._props['src'] = src
         self._props['controls'] = controls
         self._props['autoplay'] = autoplay

+ 5 - 2
nicegui/event_listener.py

@@ -1,6 +1,8 @@
 import uuid
 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
 
@@ -10,11 +12,12 @@ class EventListener:
     id: str = field(init=False)
     element_id: int
     type: str
-    args: List[str]
+    args: Optional[List[str]]
     handler: Callable
     throttle: float
     leading_events: bool
     trailing_events: bool
+    request: Optional[Request]
 
     def __post_init__(self) -> None:
         self.id = str(uuid.uuid4())

+ 9 - 7
nicegui/events.py

@@ -1,9 +1,9 @@
 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 .helpers import KWONLY_SLOTS, is_coroutine
+from .helpers import KWONLY_SLOTS
 
 if TYPE_CHECKING:
     from .client import Client
@@ -271,15 +271,17 @@ class KeyEventArguments(EventArguments):
 def handle_event(handler: Optional[Callable[..., Any]],
                  arguments: Union[EventArguments, Dict], *,
                  sender: Optional['Element'] = None) -> None:
+    if handler is None:
+        return
     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
         assert sender is not None and sender.parent_slot is not None
+        if sender.is_ignoring_events:
+            return
         with sender.parent_slot:
             result = handler() if no_arguments else handler(arguments)
-        if is_coroutine(handler):
+        if isinstance(result, Awaitable):
             async def wait_for_result():
                 with sender.parent_slot:
                     await result

+ 36 - 16
nicegui/favicon.py

@@ -1,42 +1,57 @@
+import base64
+import io
 import urllib.parse
 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 .helpers import is_file
 
 if TYPE_CHECKING:
     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:
     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'
+    favicon = str(favicon).strip()
+    if is_remote_url(favicon):
+        return favicon
     elif is_data_url(favicon):
         return favicon
     elif is_svg(favicon):
         return svg_to_data_url(favicon)
     elif is_char(favicon):
-        return char_to_data_url(favicon)
+        return svg_to_data_url(char_to_svg(favicon))
     elif page.path == '/':
         return f'{prefix}/favicon.ico'
     else:
         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:
     return favicon.startswith('http://') or favicon.startswith('https://')
 
@@ -53,8 +68,8 @@ def is_data_url(favicon: str) -> bool:
     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" >
             <style>
                 @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>
         </svg>
     '''
-    return svg_to_data_url(svg)
 
 
 def svg_to_data_url(svg: str) -> str:
     svg_urlencoded = urllib.parse.quote(svg)
     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
 
 
 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
 
     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 typing import Any, Callable, Dict, List
+from typing import Any, Awaitable, Callable, Dict, List, Tuple, Union
 
 from typing_extensions import Self
 
 from .. import background_tasks, globals
 from ..dependencies import register_component
 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')
 
@@ -15,11 +15,11 @@ register_component('refreshable', __file__, 'refreshable.js')
 class RefreshableTarget:
     container: Element
     instance: Any
-    args: List[Any]
+    args: Tuple[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:
                 with self.container:
                     if self.instance is None:
@@ -33,6 +33,7 @@ class RefreshableTarget:
                     func(*self.args, **self.kwargs)
                 else:
                     func(self.instance, *self.args, **self.kwargs)
+            return None  # required by mypy
 
 
 class refreshable:
@@ -51,7 +52,7 @@ class refreshable:
         self.instance = instance
         return self
 
-    def __call__(self, *args: Any, **kwargs: Any) -> None:
+    def __call__(self, *args: Any, **kwargs: Any) -> Union[None, Awaitable]:
         self.prune()
         target = RefreshableTarget(container=Element('refreshable'), instance=self.instance, args=args, kwargs=kwargs)
         self.targets.append(target)
@@ -64,7 +65,8 @@ class refreshable:
                 continue
             target.container.clear()
             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():
                     background_tasks.create(result)
                 else:

+ 10 - 5
nicegui/functions/timer.py

@@ -1,10 +1,11 @@
 import asyncio
 import time
-from typing import Any, Callable
+from typing import Any, Callable, Optional
 
 from .. import background_tasks, globals
 from ..binding import BindableProperty
-from ..helpers import is_coroutine
+from ..helpers import is_coroutine_function
+from ..slot import Slot
 
 
 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`)
         """
         self.interval = interval
-        self.callback = callback
+        self.callback: Optional[Callable[..., Any]] = callback
         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
         if globals.state == globals.State.STARTED:
@@ -43,6 +44,7 @@ class Timer:
         try:
             if not await self._connected():
                 return
+            assert self.slot is not None
             with self.slot:
                 await asyncio.sleep(self.interval)
                 if globals.state not in {globals.State.STOPPING, globals.State.STOPPED}:
@@ -54,6 +56,7 @@ class Timer:
         try:
             if not await self._connected():
                 return
+            assert self.slot is not None
             with self.slot:
                 while True:
                     if self.slot.parent.client.id not in globals.clients:
@@ -76,8 +79,9 @@ class Timer:
 
     async def _invoke_callback(self) -> None:
         try:
+            assert self.callback is not None
             result = self.callback()
-            if is_coroutine(self.callback):
+            if is_coroutine_function(self.callback):
                 await result
         except Exception as e:
             globals.handle_exception(e)
@@ -88,6 +92,7 @@ class Timer:
         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.
         """
+        assert self.slot is not None
         if self.slot.parent.client.shared:
             return True
         else:

+ 4 - 3
nicegui/globals.py

@@ -3,7 +3,8 @@ import inspect
 import logging
 from contextlib import contextmanager
 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 uvicorn import Server
@@ -35,7 +36,7 @@ ui_run_has_been_called: bool = False
 reload: bool
 title: str
 viewport: str
-favicon: Optional[str]
+favicon: Optional[Union[str, Path]]
 dark: Optional[bool]
 language: Language
 binding_refresh_interval: float
@@ -86,7 +87,7 @@ def get_client() -> 'Client':
 
 
 @contextmanager
-def socket_id(id: str) -> None:
+def socket_id(id: str) -> Iterator[None]:
     global _socket_id
     _socket_id = id
     yield

+ 72 - 2
nicegui/helpers.py

@@ -1,28 +1,52 @@
 import asyncio
 import functools
 import inspect
+import mimetypes
 import socket
 import sys
 import threading
 import time
 import webbrowser
 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 .storage import RequestTrackingMiddleware
 
 if TYPE_CHECKING:
     from .client import Client
 
+mimetypes.init()
+
 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):
         object = object.func
     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:
     try:
         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.start()
     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 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
 
+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)
 class Native:
     start_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 multiprocessing
+import logging
+import multiprocessing as mp
+import queue
 import socket
 import sys
 import tempfile
 import time
 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:
     with warnings.catch_warnings():
@@ -15,10 +18,16 @@ try:
         warnings.filterwarnings('ignore', category=DeprecationWarning)
         import webview
 except ModuleNotFoundError:
+    class webview:
+        class Window:
+            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):
         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)
 
     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)
     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)
 
 
+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 check_shutdown() -> None:
         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)
         _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()
+
     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.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 .client import Client
 from .dependencies import js_components, js_dependencies
 from .element import Element
 from .error import error_content
-from .helpers import safe_invoke
+from .helpers import is_file, safe_invoke
 from .page import page
 
 globals.app = app = App(default_response_class=NiceGUIJSONResponse)
 # NOTE we use custom json module which wraps orjson
 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)
 static_files = StaticFiles(
@@ -66,6 +66,13 @@ def handle_startup(with_welcome_message: bool = True) -> None:
                            'remove the guard or replace it with\n'
                            '   if __name__ in {"__main__", "__mp_main__"}:\n'
                            '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.loop = asyncio.get_running_loop()
     with globals.index_client:
@@ -97,7 +104,9 @@ def print_welcome_message():
 
 
 @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
     with globals.index_client:
         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:
     from .element import Element
 
-ClientId = int
+ClientId = str
 ElementId = int
 MessageType = str
 Message = Tuple[ClientId, MessageType, Any]
@@ -32,8 +32,8 @@ async def loop() -> None:
         coros = []
         try:
             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()
             for client_id, message_type, data in message_queue:
                 coros.append(globals.sio.emit(message_type, data, room=client_id))

+ 17 - 6
nicegui/page.py

@@ -1,7 +1,8 @@
 import asyncio
 import inspect
 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
 
@@ -10,6 +11,9 @@ from .client import Client
 from .favicon import create_favicon_route
 from .language import Language
 
+if TYPE_CHECKING:
+    from .api_router import APIRouter
+
 
 class page:
 
@@ -17,16 +21,17 @@ class page:
                  path: str, *,
                  title: Optional[str] = None,
                  viewport: Optional[str] = None,
-                 favicon: Optional[str] = None,
+                 favicon: Optional[Union[str, Path]] = None,
                  dark: Optional[bool] = ...,
                  language: Language = ...,
                  response_timeout: float = 3.0,
+                 api_router: Optional['APIRouter'] = None,
                  **kwargs: Any,
                  ) -> None:
         """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 
         (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 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 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
         """
-        self.path = path
+        self._path = path
         self.title = title
         self.viewport = viewport
         self.favicon = favicon
@@ -47,9 +53,14 @@ class page:
         self.language = language
         self.response_timeout = response_timeout
         self.kwargs = kwargs
+        self.api_router = api_router or globals.app.router
 
         create_favicon_route(self.path, favicon)
 
+    @property
+    def path(self) -> str:
+        return self.api_router.prefix + self._path
+
     def resolve_title(self) -> str:
         return self.title if self.title is not None else globals.title
 
@@ -96,6 +107,6 @@ class page:
             parameters.insert(0, request)
         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
         return func

+ 27 - 5
nicegui/run.py

@@ -1,24 +1,41 @@
 import logging
 import multiprocessing
 import os
+import socket
 import sys
-from typing import Any, List, Optional, Tuple
+from pathlib import Path
+from typing import Any, List, Optional, Tuple, Union
 
 import __main__
 import uvicorn
 from uvicorn.main import STARTUP_FAILURE
 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
 
 
+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(*,
         host: Optional[str] = None,
         port: int = 8080,
         title: str = 'NiceGUI',
         viewport: str = 'width=device-width, initial-scale=1',
-        favicon: Optional[str] = None,
+        favicon: Optional[Union[str, Path]] = None,
         dark: Optional[bool] = False,
         language: Language = 'en-US',
         binding_refresh_interval: float = 0.1,
@@ -33,6 +50,7 @@ def run(*,
         uvicorn_reload_excludes: str = '.*, .py[cod], .sw.*, ~*',
         exclude: str = '',
         tailwind: bool = True,
+        storage_secret: Optional[str] = None,
         **kwargs: Any,
         ) -> None:
     '''ui.run
@@ -59,7 +77,8 @@ def run(*,
     :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)
     :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.reload = reload
@@ -115,7 +134,10 @@ def run(*,
         log_level=uvicorn_logging_level,
         **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):
         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 nicegui import globals
+from nicegui.helpers import set_storage_secret
 from nicegui.language import Language
 from nicegui.nicegui import handle_shutdown, handle_startup
 
@@ -11,12 +13,13 @@ def run_with(
     app: FastAPI, *,
     title: str = 'NiceGUI',
     viewport: str = 'width=device-width, initial-scale=1',
-    favicon: Optional[str] = None,
+    favicon: Optional[Union[str, Path]] = None,
     dark: Optional[bool] = False,
     language: Language = 'en-US',
     binding_refresh_interval: float = 0.1,
     exclude: str = '',
     mount_path: str = '/',
+    storage_secret: Optional[str] = None,
 ) -> None:
     globals.ui_run_has_been_called = True
     globals.title = title
@@ -28,6 +31,7 @@ def run_with(
     globals.excludes = [e.strip() for e in exclude.split(',')]
     globals.tailwind = True
 
+    set_storage_secret(storage_secret)
     app.on_event('startup')(lambda: handle_startup(with_welcome_message=False))
     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 typing import TYPE_CHECKING, List, Optional, overload
+from typing import TYPE_CHECKING, List, Optional, Union, overload
 
 if TYPE_CHECKING:
     from .element import Element
@@ -177,10 +177,10 @@ class PseudoElement:
 class Tailwind:
 
     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
-    def __call__(self, Tailwind) -> Tailwind:
+    def __call__(self, tailwind: Tailwind) -> Tailwind:
         ...
 
     @overload
@@ -188,6 +188,8 @@ class Tailwind:
         ...
 
     def __call__(self, *args) -> Tailwind:
+        if not args:
+            return self
         if isinstance(args[0], Tailwind):
             args[0].apply(self.element)
         else:

+ 5 - 4
nicegui/templates/index.html

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

File diff ditekan karena terlalu besar
+ 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
 pywebview = "^4.0.2"
 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]
 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
         continue
     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
         check $path/start.sh dev || error=1 
     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]:
     for path in {'/'}.union(globals.page_routes.values()):
         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)
+    globals.app.storage.clear()
     globals.index_client = Client(page('/'), shared=True).__enter__()
     globals.app.get('/')(globals.index_client.build_response)
 

+ 2 - 1
tests/requirements.txt

@@ -3,4 +3,5 @@ pytest-selenium
 pytest-asyncio
 selenium
 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 .test_helpers import TEST_DIR
+
 PORT = 3392
 IGNORED_CLASSES = ['row', 'column', 'q-card', 'q-field', 'q-field__label', 'q-input']
 
 
 class Screen:
     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:
         self.selenium = selenium
         self.caplog = caplog
         self.server_thread = None
+        self.ui_run_kwargs = {'port': PORT, 'show': False, 'reload': False}
 
     def start_server(self) -> None:
         '''Start the webserver in a separate thread. This is the equivalent of `ui.run()` in a normal script.'''
-        self.server_thread = threading.Thread(target=ui.run, kwargs=self.UI_RUN_KWARGS)
+        self.server_thread = threading.Thread(target=ui.run, kwargs=self.ui_run_kwargs)
         self.server_thread.start()
 
     @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 pytest
 from selenium.webdriver.common.by import By
+from typing_extensions import Literal
 
 from nicegui import ui
 from nicegui.events import ClickEventArguments
@@ -26,21 +28,29 @@ async def click_async_with_args(_: ClickEventArguments):
     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):
     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_async_no_args', on_click=click_async_no_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.click('click_sync_no_args')
     screen.click('click_sync_with_args')
     screen.click('click_async_no_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_with_args')
     screen.should_contain('click_async_no_args')
     screen.should_contain('click_async_with_args')
+    screen.should_contain('click_lambda_with_async_and_parameters: works')
 
 
 def test_generic_events(screen: Screen):
@@ -150,3 +160,17 @@ def test_throttling_variants(screen: Screen):
     assert events == []
     screen.wait(1.1)
     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 time
 import webbrowser
+from pathlib import Path
 
 from nicegui import helpers
 
+TEST_DIR = Path(__file__).parent
+
 
 def test_is_port_open():
     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/'
     screen.click('change href')
     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.click('push')
     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.should_contain('B')
     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
 
 
-def test_tabs(screen: Screen):
+def test_with_strings(screen: Screen):
     with ui.tabs() as tabs:
         ui.tab('One')
         ui.tab('Two')
@@ -18,3 +18,22 @@ def test_tabs(screen: Screen):
     screen.should_contain('First tab')
     screen.click('Two')
     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 re
 from typing import Callable, Optional, Union
 
 import isort
@@ -15,13 +16,18 @@ BROWSER_BGCOLOR = '#00000010'
 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:
     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 = [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'):
             del code[0]
         del code[0]
@@ -31,7 +37,7 @@ def demo(f: Callable) -> Callable:
             del code[0]
         indentation = len(code[0]) - len(code[0].lstrip())
         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]
         if not code[-1].startswith('ui.run('):
             code.append('')

+ 59 - 89
website/documentation.py

@@ -166,26 +166,7 @@ def create_full() -> None:
 
     load_demo(ui.expansion)
     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)
 
     @text_demo('Tooltips', '''
@@ -195,7 +176,7 @@ def create_full() -> None:
     ''')
     def tooltips_demo():
         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')
 
     load_demo(ui.notify)
@@ -216,7 +197,7 @@ def create_full() -> None:
     ''')
     def design_demo():
         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')
 
     subheading('Try styling NiceGUI elements!')
@@ -395,12 +376,12 @@ def create_full() -> None:
     ''')
     def page_layout_demo():
         @ui.page('/page_layout')
-        async def page_layout():
+        def page_layout():
             ui.label('CONTENT')
             [ui.label(f'Line {i}') for i in range(100)]
             with ui.header(elevated=True).style('background-color: #3874c8').classes('items-center justify-between'):
                 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'):
                 ui.label('LEFT 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.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)
 
     heading('Routes')
 
+    subheading('Static files')
+
     @element_demo(app.add_static_files)
     def add_static_files_demo():
         from nicegui import app
@@ -455,6 +429,25 @@ def create_full() -> None:
         ui.link('Custom FastAPI app', '/examples/fastapi/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', '''
         NiceGUI is based on [FastAPI](https://fastapi.tiangolo.com/).
         This means you can use all of FastAPI's features.
@@ -509,6 +502,8 @@ def create_full() -> None:
         global dt
         dt = datetime.now()
 
+    subheading('Shutdown')
+
     @element_demo(app.shutdown)
     def shutdown_demo():
         from nicegui import app
@@ -541,38 +536,7 @@ def create_full() -> None:
             ui.button('Add label', on_click=lambda: ui.label('Click!'))
             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')
 
@@ -583,22 +547,28 @@ def create_full() -> None:
     demo.BROWSER_BGCOLOR = '#ffffff'
 
     @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`.
-        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.
-        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'))
     def native_mode_demo():
         from nicegui import app
 
-        ui.label('app running in native mode')
-
         app.native.window_args['resizable'] = False
         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)
+        # END OF DEMO
+        ui.button('enlarge', on_click=lambda: ui.notify('window will be set to 1000x700 in native mode'))
     # HACK: restore color
     demo.BROWSER_BGCOLOR = demo_BROWSER_BGCOLOR
 
@@ -707,7 +677,7 @@ def create_full() -> None:
                     '--name', 'myapp', # name of your app
                     '--onefile',
                     #'--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)
                 ```

+ 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:
+    ui.link_target(create_anchor_name(text))
     ui.html(f'<em>{text}</em>').classes('mt-8 text-3xl font-weight-500')
     if make_menu_entry:
         with get_menu():
@@ -93,10 +94,11 @@ class element_demo:
         self.element_class = element_class
 
     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)
         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)
             result = demo(f)
             if more_link:
@@ -105,7 +107,7 @@ class element_demo:
 
 
 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:
         module = importlib.import_module(f'website.more_documentation.{name}_documentation')
     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.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')

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini