浏览代码

Merge branch 'main' into v1.3

# Conflicts:
#	nicegui/functions/notify.py
Falko Schindler 1 年之前
父节点
当前提交
e69a33352f
共有 100 个文件被更改,包括 1363 次插入535 次删除
  1. 3 2
      .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. 109 0
      examples/simpy/async_realtime_environment.py
  17. 49 0
      examples/simpy/main.py
  18. 2 0
      examples/simpy/requirements.txt
  19. 6 5
      examples/single_page_app/main.py
  20. 2 2
      examples/sqlite_database/main.py
  21. 1 1
      examples/table_and_slots/main.py
  22. 32 40
      examples/todo_list/main.py
  23. 5 3
      fetch_tailwind.py
  24. 1 1
      fly.toml
  25. 17 16
      main.py
  26. 2 1
      nicegui/__init__.py
  27. 44 0
      nicegui/api_router.py
  28. 90 7
      nicegui/app.py
  29. 3 1
      nicegui/background_tasks.py
  30. 27 17
      nicegui/binding.py
  31. 7 7
      nicegui/client.py
  32. 0 33
      nicegui/colors.py
  33. 3 2
      nicegui/dependencies.py
  34. 18 11
      nicegui/element.py
  35. 3 2
      nicegui/elements/aggrid.py
  36. 17 1
      nicegui/elements/audio.js
  37. 7 2
      nicegui/elements/audio.py
  38. 4 7
      nicegui/elements/avatar.py
  39. 4 5
      nicegui/elements/badge.py
  40. 10 6
      nicegui/elements/button.py
  41. 2 2
      nicegui/elements/checkbox.py
  42. 5 1
      nicegui/elements/choice_element.py
  43. 9 5
      nicegui/elements/color_input.py
  44. 2 2
      nicegui/elements/color_picker.py
  45. 2 2
      nicegui/elements/date.py
  46. 3 6
      nicegui/elements/icon.py
  47. 28 0
      nicegui/elements/image.js
  48. 10 3
      nicegui/elements/image.py
  49. 60 0
      nicegui/elements/input.js
  50. 17 33
      nicegui/elements/input.py
  51. 11 1
      nicegui/elements/interactive_image.js
  52. 9 4
      nicegui/elements/interactive_image.py
  53. 29 20
      nicegui/elements/joystick.py
  54. 2 2
      nicegui/elements/keyboard.py
  55. 3 4
      nicegui/elements/knob.py
  56. 9 3
      nicegui/elements/line_plot.py
  57. 13 4
      nicegui/elements/link.py
  58. 5 1
      nicegui/elements/log.js
  59. 5 2
      nicegui/elements/log.py
  60. 6 2
      nicegui/elements/menu.py
  61. 1 1
      nicegui/elements/mermaid.py
  62. 41 0
      nicegui/elements/mixins/color_elements.py
  63. 8 5
      nicegui/elements/mixins/content_element.py
  64. 16 5
      nicegui/elements/mixins/disableable_element.py
  65. 8 5
      nicegui/elements/mixins/filter_element.py
  66. 16 8
      nicegui/elements/mixins/source_element.py
  67. 8 5
      nicegui/elements/mixins/text_element.py
  68. 27 0
      nicegui/elements/mixins/validation_element.py
  69. 17 9
      nicegui/elements/mixins/value_element.py
  70. 17 8
      nicegui/elements/mixins/visibility.py
  71. 34 18
      nicegui/elements/number.py
  72. 1 0
      nicegui/elements/plotly.py
  73. 5 7
      nicegui/elements/progress.py
  74. 2 1
      nicegui/elements/pyplot.py
  75. 5 1
      nicegui/elements/radio.py
  76. 2 1
      nicegui/elements/scene.py
  77. 4 4
      nicegui/elements/scene_object3d.py
  78. 44 10
      nicegui/elements/select.py
  79. 1 0
      nicegui/elements/separator.py
  80. 3 2
      nicegui/elements/slider.py
  81. 3 5
      nicegui/elements/spinner.py
  82. 3 2
      nicegui/elements/splitter.py
  83. 69 0
      nicegui/elements/stepper.py
  84. 2 2
      nicegui/elements/switch.py
  85. 30 0
      nicegui/elements/table.js
  86. 7 4
      nicegui/elements/table.py
  87. 29 14
      nicegui/elements/tabs.py
  88. 8 4
      nicegui/elements/textarea.py
  89. 4 4
      nicegui/elements/time.py
  90. 5 1
      nicegui/elements/toggle.py
  91. 4 3
      nicegui/elements/tree.py
  92. 7 3
      nicegui/elements/upload.js
  93. 9 7
      nicegui/elements/upload.py
  94. 20 6
      nicegui/elements/video.js
  95. 7 2
      nicegui/elements/video.py
  96. 5 2
      nicegui/event_listener.py
  97. 10 8
      nicegui/events.py
  98. 36 16
      nicegui/favicon.py
  99. 2 2
      nicegui/functions/javascript.py
  100. 1 1
      nicegui/functions/notify.py

+ 3 - 2
.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
@@ -25,7 +25,8 @@ jobs:
           poetry config virtualenvs.create false
           poetry install
           # install packages to run the examples
-          pip install opencv-python opencv-contrib-python-headless httpx replicate langchain openai
+          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.14
-date-released: '2023-05-14'
+version: v1.2.22
+date-released: '2023-06-24'
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.7933863
+doi: 10.5281/zenodo.8076547

+ 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', password=True, password_toggle_button=True).on('keydown.enter', try_login)
         ui.button('Log in', on_click=try_login)
 
 
-@ui.page('/logout')
-def logout(request: Request) -> None:
-    if is_authenticated(request):
-        session_info.pop(request.session['id'])
-        request.session['id'] = None
-        return RedirectResponse('/login')
-    return RedirectResponse('/')
-
-
-ui.run()
+ui.run(storage_secret='THIS_NEEDS_TO_BE_CHANGED')

+ 10 - 3
examples/fastapi/frontend.py

@@ -1,11 +1,18 @@
 from fastapi import FastAPI
 
-from 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)

+ 109 - 0
examples/simpy/async_realtime_environment.py

@@ -0,0 +1,109 @@
+import asyncio
+from time import monotonic
+from typing import Any, Optional, Union
+
+from numpy import Infinity
+from simpy.core import EmptySchedule, Environment, Infinity, SimTime, StopSimulation
+from simpy.events import URGENT, Event
+from simpy.rt import RealtimeEnvironment
+
+
+class AsyncRealtimeEnvironment(RealtimeEnvironment):
+    """A real-time simulation environment that uses asyncio.
+
+    The methods step and run are a 1-1 copy of the original methods from simpy.rt.RealtimeEnvironment,
+    except that they are async and await asyncio.sleep instead of time.sleep.
+    """
+
+    async def step(self) -> None:
+        """Process the next event after enough real-time has passed for the
+        event to happen.
+
+        The delay is scaled according to the real-time :attr:`factor`. With
+        :attr:`strict` mode enabled, a :exc:`RuntimeError` will be raised, if
+        the event is processed too slowly.
+
+        """
+        evt_time = self.peek()
+
+        if evt_time is Infinity:
+            raise EmptySchedule()
+
+        real_time = self.real_start + (evt_time - self.env_start) * self.factor
+
+        if self.strict and monotonic() - real_time > self.factor:
+            # Events scheduled for time *t* may take just up to *t+1*
+            # for their computation, before an error is raised.
+            delta = monotonic() - real_time
+            raise RuntimeError(
+                f'Simulation too slow for real time ({delta:.3f}s).'
+            )
+
+        # Sleep in a loop to fix inaccuracies of windows (see
+        # http://stackoverflow.com/a/15967564 for details) and to ignore
+        # interrupts.
+        while True:
+            delta = real_time - monotonic()
+            if delta <= 0:
+                break
+            await asyncio.sleep(delta)
+
+        Environment.step(self)
+
+    async def run(
+        self, until: Optional[Union[SimTime, Event]] = None
+    ) -> Optional[Any]:
+        """Executes :meth:`step()` until the given criterion *until* is met.
+
+        - If it is ``None`` (which is the default), this method will return
+          when there are no further events to be processed.
+
+        - If it is an :class:`~simpy.events.Event`, the method will continue
+          stepping until this event has been triggered and will return its
+          value.  Raises a :exc:`RuntimeError` if there are no further events
+          to be processed and the *until* event was not triggered.
+
+        - If it is a number, the method will continue stepping
+          until the environment's time reaches *until*.
+
+        """
+        if until is not None:
+            if not isinstance(until, Event):
+                # Assume that *until* is a number if it is not None and
+                # not an event.  Create a Timeout(until) in this case.
+                at: SimTime
+                if isinstance(until, int):
+                    at = until
+                else:
+                    at = float(until)
+
+                if at <= self.now:
+                    raise ValueError(
+                        f'until(={at}) must be > the current simulation time.'
+                    )
+
+                # Schedule the event before all regular timeouts.
+                until = Event(self)
+                until._ok = True
+                until._value = None
+                self.schedule(until, URGENT, at - self.now)
+
+            elif until.callbacks is None:
+                # Until event has already been processed.
+                return until.value
+
+            until.callbacks.append(StopSimulation.callback)
+
+        try:
+            while True:
+                await self.step()
+        except StopSimulation as exc:
+            return exc.args[0]  # == until.value
+        except EmptySchedule:
+            if until is not None:
+                assert not until.triggered
+                raise RuntimeError(
+                    f'No scheduled events left but "until" event was not '
+                    f'triggered: {until}'
+                )
+        return None

+ 49 - 0
examples/simpy/main.py

@@ -0,0 +1,49 @@
+#!/usr/bin/env python3
+import asyncio
+import datetime
+
+from async_realtime_environment import AsyncRealtimeEnvironment
+
+from nicegui import ui
+
+start_time = datetime.datetime.now()
+
+
+def clock(env):
+    while True:
+        simulation_time = start_time + datetime.timedelta(seconds=env.now)
+        clock_label.text = simulation_time.strftime('%H:%M:%S')
+        yield env.timeout(1)
+
+
+def traffic_light(env):
+    while True:
+        light.classes('bg-green-500', remove='bg-red-500')
+        yield env.timeout(30)
+        light.classes('bg-yellow-500', remove='bg-green-500')
+        yield env.timeout(5)
+        light.classes('bg-red-500', remove='bg-yellow-500')
+        yield env.timeout(20)
+
+
+async def run_simpy():
+    env = AsyncRealtimeEnvironment(factor=0.1)  # fast forward simulation with 1/10th of realtime
+    env.process(traffic_light(env))
+    env.process(clock(env))
+    try:
+        await env.run(until=300)  # run until 300 seconds of simulation time have passed
+    except asyncio.CancelledError:
+        return
+    ui.notify('Simulation completed')
+    content.classes('opacity-0')  # fade out the content
+
+# define the UI
+with ui.column().classes('absolute-center items-center transition-opacity duration-500') as content:
+    ui.label('SimPy Traffic Light Demo').classes('text-2xl mb-6')
+    light = ui.element('div').classes('w-10 h-10 rounded-full shadow-lg transition')
+    clock_label = ui.label()
+
+# start the simpy simulation as an async task in the background as soon as the UI is ready
+ui.timer(0, run_simpy, once=True)
+
+ui.run()

+ 2 - 0
examples/simpy/requirements.txt

@@ -0,0 +1,2 @@
+nicegui>=1.2
+simpy

+ 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 - 16
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,9 +78,9 @@ 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.button(on_click=lambda: menu.open()).props('flat color=white icon=menu').classes('lg:hidden'):
             with ui.menu().classes('bg-primary text-white text-lg') as menu:
@@ -87,18 +89,20 @@ def add_header(menu: Optional[ui.left_drawer] = None) -> None:
         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('/')
@@ -209,7 +213,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', [
@@ -221,7 +225,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', [
@@ -283,6 +287,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')
@@ -330,17 +335,13 @@ def documentation_page() -> None:
     ui.add_head_html('<style>html {scroll-behavior: auto;}</style>')
     with ui.column().classes('w-full p-8 lg:p-16 max-w-[1250px] mx-auto'):
         section_heading('Reference, Demos and more', '*NiceGUI* Documentation')
-        ui.markdown('''
-            This is the documentation for NiceGUI >= 1.0.
-            Documentation for older versions can be found at [https://0.9.nicegui.io/](https://0.9.nicegui.io/reference).
-        ''').classes('bold-links arrow-links')
         documentation.create_full()
 
 
 @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):
@@ -355,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 - 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:path}')
+        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) -> str:
+        """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)

+ 27 - 17
nicegui/binding.py

@@ -2,39 +2,48 @@ 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
 
 bindings: DefaultDict[Tuple[int, str], List] = defaultdict(list)
 bindable_properties: Dict[Tuple[int, str], Any] = {}
-active_links: List[Tuple[Any, str, Any, str, Callable]] = []
+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,20 +57,21 @@ 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) -> None:
+def bind_to(self_obj: Any, self_name: str, other_obj: Any, other_name: str, forward: Callable[[Any], Any]) -> None:
     bindings[(id(self_obj), self_name)].append((self_obj, other_obj, other_name, forward))
     if (id(self_obj), self_name) not in bindable_properties:
         active_links.append((self_obj, self_name, other_obj, other_name, forward))
     propagate(self_obj, self_name)
 
 
-def bind_from(self_obj: Any, self_name: str, other_obj: Any, other_name: str, backward: Callable) -> None:
+def bind_from(self_obj: Any, self_name: str, other_obj: Any, other_name: str, backward: Callable[[Any], Any]) -> None:
     bindings[(id(other_obj), other_name)].append((other_obj, self_obj, self_name, backward))
     if (id(other_obj), other_name) not in bindable_properties:
         active_links.append((other_obj, other_name, self_obj, self_name, backward))
@@ -69,14 +79,14 @@ def bind_from(self_obj: Any, self_name: str, other_obj: Any, other_name: str, ba
 
 
 def bind(self_obj: Any, self_name: str, other_obj: Any, other_name: str, *,
-         forward: Callable = lambda x: x, backward: Callable = lambda x: x) -> None:
+         forward: Callable[[Any], Any] = lambda x: x, backward: Callable[[Any], Any] = lambda x: x) -> None:
     bind_from(self_obj, self_name, other_obj, other_name, backward=backward)
     bind_to(self_obj, self_name, other_obj, other_name, forward=forward)
 
 
 class BindableProperty:
 
-    def __init__(self, on_change: Optional[Callable] = None) -> None:
+    def __init__(self, on_change: Optional[Callable[..., Any]] = None) -> None:
         self.on_change = on_change
 
     def __set_name__(self, _, name: str) -> None:

+ 7 - 7
nicegui/client.py

@@ -40,15 +40,15 @@ 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 = ''
 
         self.page = page
 
-        self.connect_handlers: List[Union[Callable, Awaitable]] = []
-        self.disconnect_handlers: List[Union[Callable, Awaitable]] = []
+        self.connect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
+        self.disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
 
     @property
     def ip(self) -> Optional[str]:
@@ -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.
@@ -130,7 +130,7 @@ class Client:
             await asyncio.sleep(check_interval)
         return self.waiting_javascript_commands.pop(request_id)
 
-    def open(self, target: Union[Callable, str]) -> None:
+    def open(self, target: Union[Callable[..., Any], str]) -> None:
         """Open a new page in the client."""
         path = target if isinstance(target, str) else globals.page_routes[target]
         outbox.enqueue_message('open', path, self.id)
@@ -139,10 +139,10 @@ class Client:
         """Download a file from the given URL."""
         outbox.enqueue_message('download', {'url': url, 'filename': filename}, self.id)
 
-    def on_connect(self, handler: Union[Callable, Awaitable]) -> None:
+    def on_connect(self, handler: Union[Callable[..., Any], Awaitable]) -> None:
         """Register a callback to be called when the client connects."""
         self.connect_handlers.append(handler)
 
-    def on_disconnect(self, handler: Union[Callable, Awaitable]) -> None:
+    def on_disconnect(self, handler: Union[Callable[..., Any], Awaitable]) -> None:
         """Register a callback to be called when the client disconnects."""
         self.disconnect_handlers.append(handler)

+ 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

+ 3 - 2
nicegui/dependencies.py

@@ -82,6 +82,7 @@ def generate_js_imports(prefix: str) -> str:
     for name, component in js_components.items():
         if name in globals.excludes:
             continue
-        result += f'import {{ default as {name} }} from "{prefix}{component.import_path}";\n'
-        result += f'app.component("{name}", {name});\n'
+        var_name = name.replace('-', '_')
+        result += f'import {{ default as {var_name} }} from "{prefix}{component.import_path}";\n'
+        result += f'app.component("{name}", {var_name});\n'
     return result

+ 18 - 11
nicegui/element.py

@@ -3,13 +3,13 @@ from __future__ import annotations
 import re
 import warnings
 from copy import deepcopy
-from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
+from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Union
 
 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
@@ -18,7 +18,7 @@ from .tailwind import Tailwind
 if TYPE_CHECKING:
     from .client import Client
 
-PROPS_PATTERN = re.compile(r'([\w\-]+)(?:=(?:("[^"\\]*(?:\\.[^"\\]*)*")|([\w\-.%:\/]+)))?(?:$|\s)')
+PROPS_PATTERN = re.compile(r'([:\w\-]+)(?:=(?:("[^"\\]*(?:\\.[^"\\]*)*")|([\w\-.%:\/]+)))?(?:$|\s)')
 
 
 class Element(Visibility):
@@ -75,9 +75,14 @@ class Element(Visibility):
     def __exit__(self, *_):
         self.default_slot.__exit__(*_)
 
-    def _collect_slot_dict(self) -> Dict[str, List[int]]:
+    def __iter__(self) -> Iterator[Element]:
+        for slot in self.slots.values():
+            for child in slot:
+                yield child
+
+    def _collect_slot_dict(self) -> Dict[str, Any]:
         return {
-            name: {'template': slot.template, 'ids': [child.id for child in slot.children]}
+            name: {'template': slot.template, 'ids': [child.id for child in slot]}
             for name, slot in self.slots.items()
         }
 
@@ -197,7 +202,7 @@ class Element(Visibility):
 
     def on(self,
            type: str,
-           handler: Optional[Callable],
+           handler: Optional[Callable[..., Any]] = None,
            args: Optional[List[str]] = None, *,
            throttle: float = 0.0,
            leading_events: bool = True,
@@ -225,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()
@@ -232,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:
@@ -251,9 +258,8 @@ class Element(Visibility):
 
     def _collect_descendant_ids(self) -> List[int]:
         ids: List[int] = [self.id]
-        for slot in self.slots.values():
-            for child in slot.children:
-                ids.extend(child._collect_descendant_ids())
+        for child in self:
+            ids.extend(child._collect_descendant_ids())
         return ids
 
     def clear(self) -> None:
@@ -272,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
@@ -286,12 +293,12 @@ class Element(Visibility):
         :param element: either the element instance or its ID
         """
         if isinstance(element, int):
-            children = [child for slot in self.slots.values() for child in slot.children]
+            children = list(self)
             element = children[element]
         binding.remove([element], Element)
         del self.client.elements[element.id]
         for slot in self.slots.values():
-            slot.children[:] = [e for e in slot.children if e.id != element.id]
+            slot.children[:] = [e for e in slot if e.id != element.id]
         self.update()
 
     def delete(self) -> None:

+ 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.

+ 17 - 1
nicegui/elements/audio.js

@@ -1,9 +1,25 @@
 export default {
-  template: `<audio :controls="controls" :autoplay="autoplay" :muted="muted" :src="src" />`,
+  template: `<audio :controls="controls" :autoplay="autoplay" :muted="muted" :src="computed_src" />`,
   props: {
     controls: Boolean,
     autoplay: Boolean,
     muted: Boolean,
     src: String,
   },
+  data: function () {
+    return {
+      computed_src: undefined,
+    };
+  },
+  mounted() {
+    setTimeout(() => this.compute_src(), 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
+  },
+  updated() {
+    this.compute_src();
+  },
+  methods: {
+    compute_src() {
+      this.computed_src = (this.src.startsWith("/") ? window.path_prefix : "") + this.src;
+    },
+  },
 };

+ 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

+ 10 - 6
nicegui/elements/button.py

@@ -1,18 +1,19 @@
 import asyncio
-from typing import Callable, Optional
+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] = None,
+                 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/checkbox.py

@@ -1,4 +1,4 @@
-from typing import Callable, Optional
+from typing import Any, Callable, Optional
 
 from .mixins.disableable_element import DisableableElement
 from .mixins.text_element import TextElement
@@ -7,7 +7,7 @@ from .mixins.value_element import ValueElement
 
 class Checkbox(TextElement, ValueElement, DisableableElement):
 
-    def __init__(self, text: str = '', *, value: bool = False, on_change: Optional[Callable] = None) -> None:
+    def __init__(self, text: str = '', *, value: bool = False, on_change: Optional[Callable[..., Any]] = None) -> None:
         """Checkbox
 
         :param text: the label to display next to the checkbox

+ 5 - 1
nicegui/elements/choice_element.py

@@ -6,7 +6,11 @@ from .mixins.value_element import ValueElement
 class ChoiceElement(ValueElement):
 
     def __init__(self, *,
-                 tag: str, options: Union[List, Dict], value: Any, on_change: Optional[Callable] = None) -> None:
+                 tag: str,
+                 options: Union[List, Dict],
+                 value: Any,
+                 on_change: Optional[Callable[..., Any]] = None,
+                 ) -> None:
         self.options = options
         self._values: List[str] = []
         self._labels: List[str] = []

+ 9 - 5
nicegui/elements/color_input.py

@@ -1,4 +1,4 @@
-from typing import Callable, Optional
+from typing import Any, Callable, Optional
 
 from nicegui import ui
 
@@ -10,8 +10,12 @@ from .mixins.value_element import ValueElement
 class ColorInput(ValueElement, DisableableElement):
     LOOPBACK = False
 
-    def __init__(self, label: Optional[str] = None, *,
-                 placeholder: Optional[str] = None, value: str = '', on_change: Optional[Callable] = None) -> None:
+    def __init__(self,
+                 label: Optional[str] = None, *,
+                 placeholder: Optional[str] = None,
+                 value: str = '',
+                 on_change: Optional[Callable[..., Any]] = None,
+                 ) -> None:
         """Color Input
 
         :param label: displayed label for the color input
@@ -27,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"""

+ 2 - 2
nicegui/elements/color_picker.py

@@ -1,4 +1,4 @@
-from typing import Callable, Dict
+from typing import Any, Callable, Dict
 
 from nicegui.events import ColorPickEventArguments, handle_event
 
@@ -8,7 +8,7 @@ from .menu import Menu
 
 class ColorPicker(Menu):
 
-    def __init__(self, *, on_pick: Callable, value: bool = False) -> None:
+    def __init__(self, *, on_pick: Callable[..., Any], value: bool = False) -> None:
         """Color Picker
 
         :param on_pick: callback to execute when a color is picked

+ 2 - 2
nicegui/elements/date.py

@@ -1,4 +1,4 @@
-from typing import Callable, Optional
+from typing import Any, Callable, Optional
 
 from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
@@ -11,7 +11,7 @@ class Date(ValueElement, DisableableElement):
                  value: Optional[str] = None,
                  *,
                  mask: str = 'YYYY-MM-DD',
-                 on_change: Optional[Callable] = None) -> None:
+                 on_change: Optional[Callable[..., Any]] = None) -> None:
         """Date Input
 
         This element is based on Quasar's `QDate <https://quasar.dev/vue-components/date>`_ component.

+ 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)

+ 28 - 0
nicegui/elements/image.js

@@ -0,0 +1,28 @@
+export default {
+  template: `
+    <q-img v-bind="$attrs" :src="computed_src">
+      <template v-for="(_, slot) in $slots" v-slot:[slot]="slotProps">
+        <slot :name="slot" v-bind="slotProps || {}" />
+      </template>
+    </q-img>
+  `,
+  props: {
+    src: String,
+  },
+  data: function () {
+    return {
+      computed_src: undefined,
+    };
+  },
+  mounted() {
+    setTimeout(() => this.compute_src(), 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
+  },
+  updated() {
+    this.compute_src();
+  },
+  methods: {
+    compute_src() {
+      this.computed_src = (this.src.startsWith("/") ? window.path_prefix : "") + this.src;
+    },
+  },
+};

+ 10 - 3
nicegui/elements/image.py

@@ -1,13 +1,20 @@
+from pathlib import Path
+from typing import Union
+
+from nicegui.dependencies import register_component
+
 from .mixins.source_element import SourceElement
 
+register_component('image', __file__, 'image.js')
+
 
 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)
+        super().__init__(tag='image', source=source)

+ 60 - 0
nicegui/elements/input.js

@@ -0,0 +1,60 @@
+export default {
+  template: `
+    <q-input
+      v-bind="$attrs"
+      v-model="inputValue"
+      :shadow-text="shadowText"
+      @keydown.tab="perform_autocomplete"
+      :list="id + '-datalist'"
+    >
+      <template v-for="(_, slot) in $slots" v-slot:[slot]="slotProps">
+        <slot :name="slot" v-bind="slotProps || {}" />
+      </template>
+    </q-input>
+    <datalist v-if="withDatalist" :id="id + '-datalist'">
+      <option v-for="option in autocomplete" :value="option"></option>
+    </datalist>
+  `,
+  props: {
+    id: String,
+    autocomplete: Array,
+    value: String,
+  },
+  data() {
+    return {
+      inputValue: this.value,
+    };
+  },
+  watch: {
+    value(newValue) {
+      this.inputValue = newValue;
+    },
+    inputValue(newValue) {
+      this.$emit("update:value", newValue);
+    },
+  },
+  computed: {
+    shadowText() {
+      if (!this.inputValue) return "";
+      const matchingOption = this.autocomplete.find((option) =>
+        option.toLowerCase().startsWith(this.inputValue.toLowerCase())
+      );
+      return matchingOption ? matchingOption.slice(this.inputValue.length) : "";
+    },
+    withDatalist() {
+      const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
+      return isMobile && this.autocomplete && this.autocomplete.length > 0;
+    },
+  },
+  methods: {
+    updateValue() {
+      this.inputValue = this.value;
+    },
+    perform_autocomplete(e) {
+      if (this.shadowText) {
+        this.inputValue += this.shadowText;
+        e.preventDefault();
+      }
+    },
+  },
+};

+ 17 - 33
nicegui/elements/input.py

@@ -1,11 +1,15 @@
 from typing import Any, Callable, Dict, List, Optional
 
+from ..dependencies import register_component
 from .icon import Icon
 from .mixins.disableable_element import DisableableElement
-from .mixins.value_element import ValueElement
+from .mixins.validation_element import ValidationElement
 
+register_component('nicegui-input', __file__, 'input.js')
 
-class Input(ValueElement, DisableableElement):
+
+class Input(ValidationElement, DisableableElement):
+    VALUE_PROP: str = 'value'
     LOOPBACK = False
 
     def __init__(self,
@@ -14,9 +18,9 @@ class Input(ValueElement, DisableableElement):
                  value: str = '',
                  password: bool = False,
                  password_toggle_button: bool = False,
-                 on_change: Optional[Callable] = None,
+                 on_change: Optional[Callable[..., Any]] = None,
                  autocomplete: Optional[List[str]] = None,
-                 validation: Dict[str, Callable] = {}) -> None:
+                 validation: Dict[str, Callable[..., bool]] = {}) -> None:
         """Text Input
 
         This element is based on Quasar's `QInput <https://quasar.dev/vue-components/input>`_ component.
@@ -35,9 +39,9 @@ class Input(ValueElement, DisableableElement):
         :param password_toggle_button: whether to show a button to toggle the password visibility (default: False)
         :param on_change: callback to execute when the value changes
         :param autocomplete: optional list of strings for autocompletion
-        :param validation: dictionary of validation rules, e.g. ``{'Too short!': lambda value: len(value) < 3}``
+        :param validation: dictionary of validation rules, e.g. ``{'Too long!': lambda value: len(value) < 3}``
         """
-        super().__init__(tag='q-input', value=value, on_value_change=on_change)
+        super().__init__(tag='nicegui-input', value=value, on_value_change=on_change, validation=validation)
         if label is not None:
             self._props['label'] = label
         if placeholder is not None:
@@ -52,34 +56,14 @@ class Input(ValueElement, DisableableElement):
                     self.props(f'type={"text" if is_hidden else "password"}')
                 icon = Icon('visibility_off').classes('cursor-pointer').on('click', toggle_type)
 
-        self.validation = validation
-
-        if autocomplete:
-            def find_autocompletion() -> Optional[str]:
-                if self.value:
-                    needle = str(self.value).casefold()
-                    for item in autocomplete:
-                        if item.casefold().startswith(needle):
-                            return item
-
-            def autocomplete_input() -> None:
-                match = find_autocompletion() or ''
-                self.props(f'shadow-text="{match[len(self.value):]}"')
-
-            def complete_input() -> None:
-                match = find_autocompletion()
-                if match:
-                    self.set_value(match)
-                self.props(f'shadow-text=""')
+        self._props['autocomplete'] = autocomplete or []
 
-            self.on('keyup', autocomplete_input)
-            self.on('keydown.tab', complete_input)
+    def set_autocomplete(self, autocomplete: Optional[List[str]]) -> None:
+        """Set the autocomplete list."""
+        self._props['autocomplete'] = autocomplete
+        self.update()
 
     def on_value_change(self, value: Any) -> None:
         super().on_value_change(value)
-        for message, check in self.validation.items():
-            if not check(value):
-                self.props(f'error error-message="{message}"')
-                break
-        else:
-            self.props(remove='error')
+        if self._send_update_on_value_change:
+            self.run_method('updateValue')

+ 11 - 1
nicegui/elements/interactive_image.js

@@ -1,7 +1,7 @@
 export default {
   template: `
     <div style="position:relative">
-      <img :src="src" style="width:100%; height:100%;" v-on="onEvents" draggable="false" />
+      <img :src="computed_src" style="width:100%; height:100%;" v-on="onEvents" draggable="false" />
       <svg style="position:absolute;top:0;left:0;pointer-events:none" :viewBox="viewBox">
         <g v-if="cross" :style="{ display: cssDisplay }">
           <line :x1="x" y1="0" :x2="x" y2="100%" stroke="black" />
@@ -18,9 +18,19 @@ export default {
       x: 100,
       y: 100,
       cssDisplay: "none",
+      computed_src: undefined,
     };
   },
+  mounted() {
+    setTimeout(() => this.compute_src(), 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
+  },
+  updated() {
+    this.compute_src();
+  },
   methods: {
+    compute_src() {
+      this.computed_src = (this.src.startsWith("/") ? window.path_prefix : "") + this.src;
+    },
     updateCrossHair(e) {
       this.x = (e.offsetX * e.target.naturalWidth) / e.target.clientWidth;
       this.y = (e.offsetY * e.target.naturalHeight) / e.target.clientHeight;

+ 9 - 4
nicegui/elements/interactive_image.py

@@ -1,6 +1,7 @@
 from __future__ import annotations
 
-from typing import 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
@@ -13,9 +14,13 @@ register_component('interactive_image', __file__, 'interactive_image.js')
 class InteractiveImage(SourceElement, ContentElement):
     CONTENT_PROP = 'content'
 
-    def __init__(self, source: str = '', *,
+    def __init__(self,
+                 source: Union[str, Path] = '', *,
                  content: str = '',
-                 on_mouse: Optional[Callable] = None, events: List[str] = ['click'], cross: bool = False) -> None:
+                 on_mouse: Optional[Callable[..., Any]] = None,
+                 events: List[str] = ['click'],
+                 cross: bool = False,
+                 ) -> None:
         """Interactive Image
 
         Create an image with an SVG overlay that handles mouse events and yields image coordinates.
@@ -24,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']`)

+ 29 - 20
nicegui/elements/joystick.py

@@ -1,4 +1,4 @@
-from typing import Any, Callable, Optional
+from typing import Any, Callable, Dict, Optional
 
 from ..dependencies import register_component
 from ..element import Element
@@ -10,9 +10,9 @@ register_component('joystick', __file__, 'joystick.vue', ['lib/nipplejs.min.js']
 class Joystick(Element):
 
     def __init__(self, *,
-                 on_start: Optional[Callable] = None,
-                 on_move: Optional[Callable] = None,
-                 on_end: Optional[Callable] = None,
+                 on_start: Optional[Callable[..., Any]] = None,
+                 on_move: Optional[Callable[..., Any]] = None,
+                 on_end: Optional[Callable[..., Any]] = None,
                  throttle: float = 0.05,
                  ** options: Any) -> None:
         """Joystick
@@ -26,20 +26,29 @@ class Joystick(Element):
         :param options: arguments like `color` which should be passed to the `underlying nipple.js library <https://github.com/yoannmoinet/nipplejs#options>`_
         """
         super().__init__('joystick')
-        self.on('start',
-                lambda _: handle_event(on_start, JoystickEventArguments(sender=self,
-                                                                        client=self.client,
-                                                                        action='start')))
-        self.on('move',
-                lambda msg: handle_event(on_move, JoystickEventArguments(sender=self,
-                                                                         client=self.client,
-                                                                         action='move',
-                                                                         x=msg['args']['data']['vector']['x'],
-                                                                         y=msg['args']['data']['vector']['y'])),
-                args=['data'],
-                throttle=throttle)
-        self.on('end',
-                lambda _: handle_event(on_end, JoystickEventArguments(sender=self,
-                                                                      client=self.client,
-                                                                      action='end')))
         self._props['options'] = options
+        self.active = False
+
+        def handle_start() -> None:
+            self.active = True
+            handle_event(on_start, JoystickEventArguments(sender=self,
+                                                          client=self.client,
+                                                          action='start'))
+
+        def handle_move(msg: Dict) -> None:
+            if self.active:
+                handle_event(on_move, JoystickEventArguments(sender=self,
+                                                             client=self.client,
+                                                             action='move',
+                                                             x=msg['args']['data']['vector']['x'],
+                                                             y=msg['args']['data']['vector']['y']))
+
+        def handle_end() -> None:
+            self.active = False
+            handle_event(on_end, JoystickEventArguments(sender=self,
+                                                        client=self.client,
+                                                        action='end'))
+
+        self.on('start', handle_start)
+        self.on('move', handle_move, args=['data'], throttle=throttle),
+        self.on('end', handle_end)

+ 2 - 2
nicegui/elements/keyboard.py

@@ -1,4 +1,4 @@
-from typing import Callable, Dict, List
+from typing import Any, Callable, Dict, List
 
 from typing_extensions import Literal
 
@@ -14,7 +14,7 @@ class Keyboard(Element):
     active = BindableProperty()
 
     def __init__(self,
-                 on_key: Callable, *,
+                 on_key: Callable[..., Any], *,
                  active: bool = True,
                  repeating: bool = True,
                  ignore: List[Literal['input', 'select', 'button', 'textarea']] = ['input', 'select', 'button', 'textarea'],

+ 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:

+ 9 - 3
nicegui/elements/line_plot.py

@@ -1,11 +1,17 @@
-from typing import List
+from typing import Any, List
 
 from .pyplot import Pyplot
 
 
 class LinePlot(Pyplot):
 
-    def __init__(self, *, n: int = 1, limit: int = 100, update_every: int = 1, close: bool = True, **kwargs) -> None:
+    def __init__(self, *,
+                 n: int = 1,
+                 limit: int = 100,
+                 update_every: int = 1,
+                 close: bool = True,
+                 **kwargs: Any,
+                 ) -> None:
         """Line Plot
 
         Create a line plot using pyplot.
@@ -26,7 +32,7 @@ class LinePlot(Pyplot):
         self.update_every = update_every
         self.push_counter = 0
 
-    def with_legend(self, titles: List[str], **kwargs):
+    def with_legend(self, titles: List[str], **kwargs: Any):
         self.fig.gca().legend(titles, **kwargs)
         self._convert_to_html()
         return self

+ 13 - 4
nicegui/elements/link.py

@@ -1,4 +1,4 @@
-from typing import Callable, Union
+from typing import Any, Callable, Union
 
 from .. import globals
 from ..dependencies import register_component
@@ -10,7 +10,11 @@ register_component('link', __file__, 'link.js')
 
 class Link(TextElement):
 
-    def __init__(self, text: str = '', target: Union[Callable, 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 callable(target):
+            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"""

+ 6 - 2
nicegui/elements/menu.py

@@ -1,4 +1,4 @@
-from typing import Callable, Optional
+from typing import Any, Callable, Optional
 
 from .. import globals
 from ..events import ClickEventArguments, handle_event
@@ -28,7 +28,11 @@ class Menu(ValueElement):
 
 class MenuItem(TextElement):
 
-    def __init__(self, text: str = '', on_click: Optional[Callable] = None, *, auto_close: bool = True) -> None:
+    def __init__(self,
+                 text: str = '',
+                 on_click: Optional[Callable[..., Any]] = None, *,
+                 auto_close: bool = True,
+                 ) -> None:
         """Menu Item
 
         A menu item to be added to a menu.

+ 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 - 5
nicegui/elements/mixins/content_element.py

@@ -10,7 +10,7 @@ class ContentElement(Element):
     CONTENT_PROP = 'innerHTML'
     content = BindableProperty(on_change=lambda sender, content: sender.on_content_change(content))
 
-    def __init__(self, *, content: str, **kwargs) -> None:
+    def __init__(self, *, content: str, **kwargs: Any) -> None:
         super().__init__(**kwargs)
         self.content = content
         self.on_content_change(content)
@@ -18,7 +18,8 @@ class ContentElement(Element):
     def bind_content_to(self,
                         target_object: Any,
                         target_name: str = 'content',
-                        forward: Callable = lambda x: x) -> Self:
+                        forward: Callable[..., Any] = lambda x: x,
+                        ) -> Self:
         """Bind the content of this element to the target object's target_name property.
 
         The binding works one way only, from this element to the target.
@@ -33,7 +34,8 @@ class ContentElement(Element):
     def bind_content_from(self,
                           target_object: Any,
                           target_name: str = 'content',
-                          backward: Callable = lambda x: x) -> Self:
+                          backward: Callable[..., Any] = lambda x: x,
+                          ) -> Self:
         """Bind the content of this element from the target object's target_name property.
 
         The binding works one way only, from the target to this element.
@@ -48,8 +50,9 @@ class ContentElement(Element):
     def bind_content(self,
                      target_object: Any,
                      target_name: str = 'content', *,
-                     forward: Callable = lambda x: x,
-                     backward: Callable = lambda x: x) -> Self:
+                     forward: Callable[..., Any] = lambda x: x,
+                     backward: Callable[..., Any] = lambda x: x,
+                     ) -> Self:
         """Bind the content of this element to the target object's target_name property.
 
         The binding works both ways, from this element to the target and from the target to this element.

+ 16 - 5
nicegui/elements/mixins/disableable_element.py

@@ -9,9 +9,17 @@ from ...element import Element
 class DisableableElement(Element):
     enabled = BindableProperty(on_change=lambda sender, value: sender.on_enabled_change(value))
 
-    def __init__(self, **kwargs) -> None:
+    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."""
@@ -24,7 +32,8 @@ class DisableableElement(Element):
     def bind_enabled_to(self,
                         target_object: Any,
                         target_name: str = 'enabled',
-                        forward: Callable = lambda x: x) -> Self:
+                        forward: Callable[..., Any] = lambda x: x,
+                        ) -> Self:
         """Bind the enabled state of this element to the target object's target_name property.
 
         The binding works one way only, from this element to the target.
@@ -39,7 +48,8 @@ class DisableableElement(Element):
     def bind_enabled_from(self,
                           target_object: Any,
                           target_name: str = 'enabled',
-                          backward: Callable = lambda x: x) -> Self:
+                          backward: Callable[..., Any] = lambda x: x,
+                          ) -> Self:
         """Bind the enabled state of this element from the target object's target_name property.
 
         The binding works one way only, from the target to this element.
@@ -54,8 +64,9 @@ class DisableableElement(Element):
     def bind_enabled(self,
                      target_object: Any,
                      target_name: str = 'enabled', *,
-                     forward: Callable = lambda x: x,
-                     backward: Callable = lambda x: x) -> Self:
+                     forward: Callable[..., Any] = lambda x: x,
+                     backward: Callable[..., Any] = lambda x: x,
+                     ) -> Self:
         """Bind the enabled state of this element to the target object's target_name property.
 
         The binding works both ways, from this element to the target and from the target to this element.

+ 8 - 5
nicegui/elements/mixins/filter_element.py

@@ -10,7 +10,7 @@ class FilterElement(Element):
     FILTER_PROP = 'filter'
     filter = BindableProperty(on_change=lambda sender, filter: sender.on_filter_change(filter))
 
-    def __init__(self, *, filter: Optional[str] = None, **kwargs) -> None:
+    def __init__(self, *, filter: Optional[str] = None, **kwargs: Any) -> None:
         super().__init__(**kwargs)
         self.filter = filter
         self._props[self.FILTER_PROP] = filter
@@ -18,7 +18,8 @@ class FilterElement(Element):
     def bind_filter_to(self,
                        target_object: Any,
                        target_name: str = 'filter',
-                       forward: Callable = lambda x: x) -> Self:
+                       forward: Callable[..., Any] = lambda x: x,
+                       ) -> Self:
         """Bind the filter of this element to the target object's target_name property.
 
         The binding works one way only, from this element to the target.
@@ -33,7 +34,8 @@ class FilterElement(Element):
     def bind_filter_from(self,
                          target_object: Any,
                          target_name: str = 'filter',
-                         backward: Callable = lambda x: x) -> Self:
+                         backward: Callable[..., Any] = lambda x: x,
+                         ) -> Self:
         """Bind the filter of this element from the target object's target_name property.
 
         The binding works one way only, from the target to this element.
@@ -48,8 +50,9 @@ class FilterElement(Element):
     def bind_filter(self,
                     target_object: Any,
                     target_name: str = 'filter', *,
-                    forward: Callable = lambda x: x,
-                    backward: Callable = lambda x: x) -> Self:
+                    forward: Callable[..., Any] = lambda x: x,
+                    backward: Callable[..., Any] = lambda x: x,
+                    ) -> Self:
         """Bind the filter of this element to the target object's target_name property.
 
         The binding works both ways, from this element to the target and from the target to this element.

+ 16 - 8
nicegui/elements/mixins/source_element.py

@@ -1,23 +1,29 @@
-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) -> 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
 
     def bind_source_to(self,
                        target_object: Any,
                        target_name: str = 'source',
-                       forward: Callable = lambda x: x) -> Self:
+                       forward: Callable[..., Any] = lambda x: x,
+                       ) -> Self:
         """Bind the source of this element to the target object's target_name property.
 
         The binding works one way only, from this element to the target.
@@ -32,7 +38,8 @@ class SourceElement(Element):
     def bind_source_from(self,
                          target_object: Any,
                          target_name: str = 'source',
-                         backward: Callable = lambda x: x) -> Self:
+                         backward: Callable[..., Any] = lambda x: x,
+                         ) -> Self:
         """Bind the source of this element from the target object's target_name property.
 
         The binding works one way only, from the target to this element.
@@ -47,8 +54,9 @@ class SourceElement(Element):
     def bind_source(self,
                     target_object: Any,
                     target_name: str = 'source', *,
-                    forward: Callable = lambda x: x,
-                    backward: Callable = lambda x: x) -> Self:
+                    forward: Callable[..., Any] = lambda x: x,
+                    backward: Callable[..., Any] = lambda x: x,
+                    ) -> Self:
         """Bind the source of this element to the target object's target_name property.
 
         The binding works both ways, from this element to the target and from the target to this element.
@@ -61,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.

+ 8 - 5
nicegui/elements/mixins/text_element.py

@@ -9,7 +9,7 @@ from ...element import Element
 class TextElement(Element):
     text = BindableProperty(on_change=lambda sender, text: sender.on_text_change(text))
 
-    def __init__(self, *, text: str, **kwargs) -> None:
+    def __init__(self, *, text: str, **kwargs: Any) -> None:
         super().__init__(**kwargs)
         self.text = text
         self._text_to_model_text(text)
@@ -17,7 +17,8 @@ class TextElement(Element):
     def bind_text_to(self,
                      target_object: Any,
                      target_name: str = 'text',
-                     forward: Callable = lambda x: x) -> Self:
+                     forward: Callable[..., Any] = lambda x: x,
+                     ) -> Self:
         """Bind the text of this element to the target object's target_name property.
 
         The binding works one way only, from this element to the target.
@@ -32,7 +33,8 @@ class TextElement(Element):
     def bind_text_from(self,
                        target_object: Any,
                        target_name: str = 'text',
-                       backward: Callable = lambda x: x) -> Self:
+                       backward: Callable[..., Any] = lambda x: x,
+                       ) -> Self:
         """Bind the text of this element from the target object's target_name property.
 
         The binding works one way only, from the target to this element.
@@ -47,8 +49,9 @@ class TextElement(Element):
     def bind_text(self,
                   target_object: Any,
                   target_name: str = 'text', *,
-                  forward: Callable = lambda x: x,
-                  backward: Callable = lambda x: x) -> Self:
+                  forward: Callable[..., Any] = lambda x: x,
+                  backward: Callable[..., Any] = lambda x: x,
+                  ) -> Self:
         """Bind the text of this element to the target object's target_name property.
 
         The binding works both ways, from this element to the target and from the target to this element.

+ 27 - 0
nicegui/elements/mixins/validation_element.py

@@ -0,0 +1,27 @@
+from typing import Any, Callable, Dict, Optional
+
+from .value_element import ValueElement
+
+
+class ValidationElement(ValueElement):
+
+    def __init__(self, validation: Dict[str, Callable[..., bool]], **kwargs: Any) -> None:
+        super().__init__(**kwargs)
+        self.validation = validation
+        self._error: Optional[str] = None
+
+    @property
+    def error(self) -> Optional[str]:
+        """The latest error message from the validation functions."""
+        return self._error
+
+    def on_value_change(self, value: Any) -> None:
+        super().on_value_change(value)
+        for message, check in self.validation.items():
+            if not check(value):
+                self._error = message
+                self.props(f'error error-message="{message}"')
+                break
+        else:
+            self._error = None
+            self.props(remove='error')

+ 17 - 9
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,12 +8,17 @@ 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, *, value: Any, on_value_change: Optional[Callable], throttle: float = 0, **kwargs) -> None:
+    def __init__(self, *,
+                 value: Any,
+                 on_value_change: Optional[Callable[..., Any]],
+                 throttle: float = 0,
+                 **kwargs: Any,
+                 ) -> None:
         super().__init__(**kwargs)
         self.set_value(value)
         self._props[self.VALUE_PROP] = self._value_to_model_value(value)
@@ -30,7 +35,8 @@ class ValueElement(Element):
     def bind_value_to(self,
                       target_object: Any,
                       target_name: str = 'value',
-                      forward: Callable = lambda x: x) -> Self:
+                      forward: Callable[..., Any] = lambda x: x,
+                      ) -> Self:
         """Bind the value of this element to the target object's target_name property.
 
         The binding works one way only, from this element to the target.
@@ -45,7 +51,8 @@ class ValueElement(Element):
     def bind_value_from(self,
                         target_object: Any,
                         target_name: str = 'value',
-                        backward: Callable = lambda x: x) -> Self:
+                        backward: Callable[..., Any] = lambda x: x,
+                        ) -> Self:
         """Bind the value of this element from the target object's target_name property.
 
         The binding works one way only, from the target to this element.
@@ -60,8 +67,9 @@ class ValueElement(Element):
     def bind_value(self,
                    target_object: Any,
                    target_name: str = 'value', *,
-                   forward: Callable = lambda x: x,
-                   backward: Callable = lambda x: x) -> Self:
+                   forward: Callable[..., Any] = lambda x: x,
+                   backward: Callable[..., Any] = lambda x: x,
+                   ) -> Self:
         """Bind the value of this element to the target object's target_name property.
 
         The binding works both ways, from this element to the target and from the target to this element.

+ 17 - 8
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
 
@@ -11,14 +11,21 @@ if TYPE_CHECKING:
 class Visibility:
     visible = BindableProperty(on_change=lambda sender, visible: sender.on_visibility_change(visible))
 
-    def __init__(self, **kwargs) -> None:
+    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,
                            target_name: str = 'visible',
-                           forward: Callable = lambda x: x) -> Self:
+                           forward: Callable[..., Any] = lambda x: x,
+                           ) -> Self:
         """Bind the visibility of this element to the target object's target_name property.
 
         The binding works one way only, from this element to the target.
@@ -33,7 +40,7 @@ class Visibility:
     def bind_visibility_from(self,
                              target_object: Any,
                              target_name: str = 'visible',
-                             backward: Callable = lambda x: x, *,
+                             backward: Callable[..., Any] = lambda x: x, *,
                              value: Any = None) -> Self:
         """Bind the visibility of this element from the target object's target_name property.
 
@@ -52,9 +59,10 @@ class Visibility:
     def bind_visibility(self,
                         target_object: Any,
                         target_name: str = 'visible', *,
-                        forward: Callable = lambda x: x,
-                        backward: Callable = lambda x: x,
-                        value: Any = None) -> Self:
+                        forward: Callable[..., Any] = lambda x: x,
+                        backward: Callable[..., Any] = lambda x: x,
+                        value: Any = None,
+                        ) -> Self:
         """Bind the visibility of this element to the target object's target_name property.
 
         The binding works both ways, from this element to the target and from the target to this element.
@@ -77,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()

+ 34 - 18
nicegui/elements/number.py

@@ -1,10 +1,10 @@
 from typing import Any, Callable, Dict, Optional
 
 from .mixins.disableable_element import DisableableElement
-from .mixins.value_element import ValueElement
+from .mixins.validation_element import ValidationElement
 
 
-class Number(ValueElement, DisableableElement):
+class Number(ValidationElement, DisableableElement):
     LOOPBACK = False
 
     def __init__(self,
@@ -17,8 +17,9 @@ class Number(ValueElement, DisableableElement):
                  prefix: Optional[str] = None,
                  suffix: Optional[str] = None,
                  format: Optional[str] = None,
-                 on_change: Optional[Callable] = None,
-                 validation: Dict[str, Callable] = {}) -> None:
+                 on_change: Optional[Callable[..., Any]] = None,
+                 validation: Dict[str, Callable[..., bool]] = {},
+                 ) -> None:
         """Number Input
 
         This element is based on Quasar's `QInput <https://quasar.dev/vue-components/input>`_ component.
@@ -36,10 +37,10 @@ class Number(ValueElement, DisableableElement):
         :param suffix: a suffix to append to the displayed value
         :param format: a string like "%.2f" to format the displayed value
         :param on_change: callback to execute when the value changes
-        :param validation: dictionary of validation rules, e.g. ``{'Too small!': lambda value: value < 3}``
+        :param validation: dictionary of validation rules, e.g. ``{'Too large!': lambda value: value < 3}``
         """
         self.format = format
-        super().__init__(tag='q-input', value=value, on_value_change=on_change)
+        super().__init__(tag='q-input', value=value, on_value_change=on_change, validation=validation)
         self._props['type'] = 'number'
         if label is not None:
             self._props['label'] = label
@@ -55,24 +56,39 @@ class Number(ValueElement, DisableableElement):
             self._props['prefix'] = prefix
         if suffix is not None:
             self._props['suffix'] = suffix
-        self.validation = validation
         self.on('blur', self.sanitize)
 
+    @property
+    def min(self) -> float:
+        """The minimum value allowed."""
+        return self._props.get('min', -float('inf'))
+
+    @min.setter
+    def min(self, value: float) -> None:
+        self._props['min'] = value
+        self.sanitize()
+
+    @property
+    def max(self) -> float:
+        """The maximum value allowed."""
+        return self._props.get('max', float('inf'))
+
+    @max.setter
+    def max(self, value: float) -> None:
+        self._props['max'] = value
+        self.sanitize()
+
+    @property
+    def out_of_limits(self) -> bool:
+        """Whether the current value is out of the allowed limits."""
+        return not self.min <= self.value <= self.max
+
     def sanitize(self) -> None:
         value = float(self.value or 0)
-        value = max(value, self._props.get('min', -float('inf')))
-        value = min(value, self._props.get('max', float('inf')))
+        value = max(value, self.min)
+        value = min(value, self.max)
         self.set_value(float(self.format % value) if self.format else value)
 
-    def on_value_change(self, value: Any) -> None:
-        super().on_value_change(value)
-        for message, check in self.validation.items():
-            if not check(value):
-                self.props(f'error error-message="{message}"')
-                break
-        else:
-            self.props(remove='error')
-
     def _msg_to_value(self, msg: Dict) -> Any:
         return float(msg['args']) if msg['args'] else None
 

+ 1 - 0
nicegui/elements/plotly.py

@@ -37,6 +37,7 @@ class Plotly(Element):
         self.update()
 
     def update(self) -> None:
+        super().update()
         self._props['options'] = self._get_figure_json()
         self.run_method('update', self._props['options'])
 

+ 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:

+ 2 - 1
nicegui/elements/pyplot.py

@@ -1,5 +1,6 @@
 import asyncio
 import io
+from typing import Any
 
 import matplotlib.pyplot as plt
 
@@ -9,7 +10,7 @@ from ..element import Element
 
 class Pyplot(Element):
 
-    def __init__(self, *, close: bool = True, **kwargs) -> None:
+    def __init__(self, *, close: bool = True, **kwargs: Any) -> None:
         """Pyplot Context
 
         Create a context to configure a `Matplotlib <https://matplotlib.org/>`_ plot.

+ 5 - 1
nicegui/elements/radio.py

@@ -6,7 +6,11 @@ from .mixins.disableable_element import DisableableElement
 
 class Radio(ChoiceElement, DisableableElement):
 
-    def __init__(self, options: Union[List, Dict], *, value: Any = None, on_change: Optional[Callable] = None):
+    def __init__(self,
+                 options: Union[List, Dict], *,
+                 value: Any = None,
+                 on_change: Optional[Callable[..., Any]] = None,
+                 ) -> None:
         """Radio Selection
 
         The options can be specified as a list of values, or as a dictionary mapping values to labels.

+ 2 - 1
nicegui/elements/scene.py

@@ -57,7 +57,8 @@ class Scene(Element):
                  width: int = 400,
                  height: int = 300,
                  grid: bool = True,
-                 on_click: Optional[Callable] = None) -> None:
+                 on_click: Optional[Callable[..., Any]] = None,
+                 ) -> None:
         """3D Scene
 
         Display a 3d scene using `three.js <https://threejs.org/>`_.

+ 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

+ 44 - 10
nicegui/elements/select.py

@@ -12,11 +12,15 @@ 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] = None,
-                 with_input: bool = False) -> None:
+                 on_change: Optional[Callable[..., Any]] = None,
+                 with_input: bool = False,
+                 multiple: bool = False,
+                 clearable: bool = False,
+                 ) -> None:
         """Dropdown Selection
 
         The options can be specified as a list of values, or as a dictionary mapping values to labels.
@@ -26,16 +30,27 @@ 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
+        :param clearable: whether to add a button to clear the selection
         """
+        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
         if with_input:
             self.original_options = deepcopy(options)
             self._props['use-input'] = True
-            self._props['hide-selected'] = True
+            self._props['hide-selected'] = not multiple
             self._props['fill-input'] = True
             self._props['input-debounce'] = 0
+        self._props['multiple'] = multiple
+        self._props['clearable'] = clearable
 
     def on_filter(self, event: Dict) -> None:
         self.options = [
@@ -46,11 +61,30 @@ class Select(ChoiceElement, DisableableElement):
         self.update()
 
     def _msg_to_value(self, msg: Dict) -> Any:
-        return self._values[msg['args']['value']]
+        if self.multiple:
+            if msg['args'] is None:
+                return []
+            else:
+                return [self._values[arg['value']] for arg in msg['args']]
+        else:
+            if msg['args'] is None:
+                return None
+            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

+ 1 - 0
nicegui/elements/separator.py

@@ -7,6 +7,7 @@ class Separator(Element):
         """Separator
 
         A separator for cards, menus and other component containers.
+        Similar to HTML's <hr> tag.
         """
         super().__init__('q-separator')
         self._classes = ['nicegui-separator']

+ 3 - 2
nicegui/elements/slider.py

@@ -1,4 +1,4 @@
-from typing import Callable, Optional
+from typing import Any, Callable, Optional
 
 from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
@@ -11,7 +11,8 @@ class Slider(ValueElement, DisableableElement):
                  max: float,
                  step: float = 1.0,
                  value: Optional[float] = None,
-                 on_change: Optional[Callable] = None) -> None:
+                 on_change: Optional[Callable[..., Any]] = None,
+                 ) -> None:
         """Slider
 
         :param min: lower bound of the slider

+ 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

+ 3 - 2
nicegui/elements/splitter.py

@@ -1,4 +1,4 @@
-from typing import Callable, Optional, Tuple
+from typing import Any, Callable, Optional, Tuple
 
 from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
@@ -11,7 +11,8 @@ class Splitter(ValueElement, DisableableElement):
                  reverse: Optional[bool] = False,
                  limits: Optional[Tuple[float, float]] = (0, 100),
                  value: Optional[float] = 50,
-                 on_change: Optional[Callable] = None) -> None:
+                 on_change: Optional[Callable[..., Any]] = None,
+                 ) -> None:
         """Splitter
 
         The `ui.splitter` element divides the screen space into resizable sections, 

+ 69 - 0
nicegui/elements/stepper.py

@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+from typing import Any, Callable, Optional, Union, cast
+
+from .. import globals
+from ..element import Element
+from .mixins.disableable_element import DisableableElement
+from .mixins.value_element import ValueElement
+
+
+class Stepper(ValueElement):
+
+    def __init__(self, *,
+                 value: Union[str, Step, None] = None,
+                 on_value_change: Optional[Callable[..., Any]] = None,
+                 ) -> None:
+        """Stepper
+
+        This element represents `Quasar's QStepper <https://quasar.dev/vue-components/stepper#qstepper-api>`_ component.
+        It contains individual steps.
+
+        :param value: `ui.step` or name of the step to be initially selected (default: `None` meaning the first step)
+        :param on_value_change: callback to be executed when the selected step changes
+        """
+        super().__init__(tag='q-stepper', value=value, on_value_change=on_value_change)
+
+    def _value_to_model_value(self, value: Any) -> Any:
+        return value._props['name'] if isinstance(value, Step) else value
+
+    def on_value_change(self, value: Any) -> None:
+        super().on_value_change(value)
+        names = [step._props['name'] for step in self]
+        for i, step in enumerate(self):
+            done = i < names.index(value) if value in names else False
+            step.props(f':done={done}')
+
+    def next(self) -> None:
+        self.run_method('next')
+
+    def previous(self) -> None:
+        self.run_method('previous')
+
+
+class Step(DisableableElement):
+
+    def __init__(self, name: str, title: Optional[str] = None, icon: Optional[str] = None) -> None:
+        """Step
+
+        This element represents `Quasar's QStep <https://quasar.dev/vue-components/stepper#qstep-api>`_ component.
+        It is a child of a `ui.stepper` element.
+
+        :param name: name of the step (will be the value of the `ui.stepper` element)
+        :param title: title of the step (default: `None`, meaning the same as `name`)
+        :param icon: icon of the step (default: `None`)
+        """
+        super().__init__(tag='q-step')
+        self._props['name'] = name
+        self._props['title'] = title if title is not None else name
+        if icon:
+            self._props['icon'] = icon
+        self.stepper = cast(ValueElement, globals.get_slot().parent)
+        if self.stepper.value is None:
+            self.stepper.value = name
+
+
+class StepperNavigation(Element):
+
+    def __init__(self) -> None:
+        super().__init__('q-stepper-navigation')

+ 2 - 2
nicegui/elements/switch.py

@@ -1,4 +1,4 @@
-from typing import Callable, Optional
+from typing import Any, Callable, Optional
 
 from .mixins.disableable_element import DisableableElement
 from .mixins.text_element import TextElement
@@ -7,7 +7,7 @@ from .mixins.value_element import ValueElement
 
 class Switch(TextElement, ValueElement, DisableableElement):
 
-    def __init__(self, text: str = '', *, value: bool = False, on_change: Optional[Callable] = None) -> None:
+    def __init__(self, text: str = '', *, value: bool = False, on_change: Optional[Callable[..., Any]] = None) -> None:
         """Switch
 
         :param text: the label to display next to the switch

+ 30 - 0
nicegui/elements/table.js

@@ -0,0 +1,30 @@
+export default {
+  template: `
+    <q-table v-bind="$attrs" :columns="convertedColumns">
+      <template v-for="(_, slot) in $slots" v-slot:[slot]="slotProps">
+        <slot :name="slot" v-bind="slotProps || {}" />
+      </template>
+    </q-table>
+  `,
+  props: {
+    columns: Array,
+  },
+  computed: {
+    convertedColumns() {
+      return this.columns.map((column) => {
+        const convertedColumn = { ...column };
+        for (const attr in convertedColumn) {
+          if (attr.startsWith(":")) {
+            try {
+              convertedColumn[attr.slice(1)] = new Function("return " + convertedColumn[attr])();
+              delete convertedColumn[attr];
+            } catch (e) {
+              console.error(`Error while converting ${attr} attribute to function:`, e);
+            }
+          }
+        }
+        return convertedColumn;
+      });
+    },
+  },
+};

+ 7 - 4
nicegui/elements/table.py

@@ -1,11 +1,14 @@
-from typing import Callable, Dict, List, Optional
+from typing import Any, Callable, Dict, List, Optional
 
 from typing_extensions import Literal
 
+from ..dependencies import register_component
 from ..element import Element
 from ..events import TableSelectionEventArguments, handle_event
 from .mixins.filter_element import FilterElement
 
+register_component('nicegui-table', __file__, 'table.js')
+
 
 class Table(FilterElement):
 
@@ -16,7 +19,7 @@ class Table(FilterElement):
                  title: Optional[str] = None,
                  selection: Optional[Literal['single', 'multiple']] = None,
                  pagination: Optional[int] = None,
-                 on_select: Optional[Callable] = None,
+                 on_select: Optional[Callable[..., Any]] = None,
                  ) -> None:
         """Table
 
@@ -30,9 +33,9 @@ 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')
+        super().__init__(tag='nicegui-table')
 
         self.rows = rows
         self.row_key = row_key

+ 29 - 14
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,18 +10,21 @@ from .mixins.value_element import ValueElement
 class Tabs(ValueElement):
 
     def __init__(self, *,
-                 value: Any = None,
-                 on_change: Optional[Callable] = None) -> None:
+                 value: Union[Tab, TabPanel, None] = None,
+                 on_change: Optional[Callable[..., Any]] = None,
+                 ) -> None:
         """Tabs
 
         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):
@@ -28,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`)
         """
@@ -46,34 +51,44 @@ class TabPanels(ValueElement):
 
     def __init__(self,
                  tabs: Tabs, *,
-                 value: Any = None,
-                 on_change: Optional[Callable] = 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

+ 8 - 4
nicegui/elements/textarea.py

@@ -1,4 +1,4 @@
-from typing import Callable, Dict, Optional
+from typing import Any, Callable, Dict, Optional
 
 from .input import Input
 
@@ -9,18 +9,22 @@ class Textarea(Input):
                  label: Optional[str] = None, *,
                  placeholder: Optional[str] = None,
                  value: str = '',
-                 on_change: Optional[Callable] = None,
-                 validation: Dict[str, Callable] = {}) -> None:
+                 on_change: Optional[Callable[..., Any]] = None,
+                 validation: Dict[str, Callable[..., bool]] = {},
+                 ) -> None:
         """Textarea
 
         This element is based on Quasar's `QInput <https://quasar.dev/vue-components/input>`_ component.
         The ``type`` is set to ``textarea`` to create a multi-line text input.
 
+        You can use the `validation` parameter to define a dictionary of validation rules.
+        The key of the first rule that fails will be displayed as an error message.
+
         :param label: displayed name for the textarea
         :param placeholder: text to show if no value is entered
         :param value: the initial value of the field
         :param on_change: callback to execute when the value changes
-        :param validation: dictionary of validation rules, e.g. ``{'Too short!': lambda value: len(value) < 3}``
+        :param validation: dictionary of validation rules, e.g. ``{'Too long!': lambda value: len(value) < 3}``
         """
         super().__init__(label, placeholder=placeholder, value=value, on_change=on_change, validation=validation)
         self._props['type'] = 'textarea'

+ 4 - 4
nicegui/elements/time.py

@@ -1,4 +1,4 @@
-from typing import Callable, Optional
+from typing import Any, Callable, Optional
 
 from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
@@ -7,10 +7,10 @@ from .mixins.value_element import ValueElement
 class Time(ValueElement, DisableableElement):
 
     def __init__(self,
-                 value: Optional[str] = None,
-                 *,
+                 value: Optional[str] = None, *,
                  mask: str = 'HH:mm',
-                 on_change: Optional[Callable] = None) -> None:
+                 on_change: Optional[Callable[..., Any]] = None,
+                 ) -> None:
         """Time Input
 
         This element is based on Quasar's `QTime <https://quasar.dev/vue-components/date>`_ component.

+ 5 - 1
nicegui/elements/toggle.py

@@ -6,7 +6,11 @@ from .mixins.disableable_element import DisableableElement
 
 class Toggle(ChoiceElement, DisableableElement):
 
-    def __init__(self, options: Union[List, Dict], *, value: Any = None, on_change: Optional[Callable] = None) -> None:
+    def __init__(self,
+                 options: Union[List, Dict], *,
+                 value: Any = None,
+                 on_change: Optional[Callable[..., Any]] = None,
+                 ) -> None:
         """Toggle
 
         The options can be specified as a list of values, or as a dictionary mapping values to labels.

+ 4 - 3
nicegui/elements/tree.py

@@ -11,9 +11,10 @@ class Tree(Element):
                  node_key: str = 'id',
                  label_key: str = 'label',
                  children_key: str = 'children',
-                 on_select: Optional[Callable] = None,
-                 on_expand: Optional[Callable] = None,
-                 on_tick: Optional[Callable] = None) -> None:
+                 on_select: Optional[Callable[..., Any]] = None,
+                 on_expand: Optional[Callable[..., Any]] = None,
+                 on_tick: Optional[Callable[..., Any]] = None,
+                 ) -> None:
         """Tree
 
         Display hierarchical data using Quasar's `QTree <https://quasar.dev/vue-components/tree>`_ component.

+ 7 - 3
nicegui/elements/upload.js

@@ -7,11 +7,15 @@ export default {
     </q-uploader>
   `,
   mounted() {
-    setTimeout(() => {
-      this.computed_url = (window.path_prefix || "") + this.url;
-    }, 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
+    setTimeout(() => this.compute_url(), 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
+  },
+  updated() {
+    this.compute_url();
   },
   methods: {
+    compute_url() {
+      this.computed_url = (this.url.startsWith("/") ? window.path_prefix : "") + this.url;
+    },
     reset() {
       this.$refs.uploader.reset();
     },

+ 9 - 7
nicegui/elements/upload.py

@@ -1,6 +1,7 @@
-from typing import 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
@@ -17,8 +18,8 @@ class Upload(DisableableElement):
                  max_file_size: Optional[int] = None,
                  max_total_size: Optional[int] = None,
                  max_files: Optional[int] = None,
-                 on_upload: Optional[Callable] = None,
-                 on_rejected: Optional[Callable] = None,
+                 on_upload: Optional[Callable[..., Any]] = None,
+                 on_rejected: Optional[Callable[..., Any]] = None,
                  label: str = '',
                  auto_upload: bool = False,
                  ) -> None:
@@ -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'}

+ 20 - 6
nicegui/elements/video.js

@@ -1,14 +1,28 @@
 export default {
-  template: `<video :controls="controls" :autoplay="autoplay" :muted="muted" :src="src" />`,
-  methods: {
-    seek(seconds) {
-      this.$el.currentTime = seconds;
-    },
-  },
+  template: `<video :controls="controls" :autoplay="autoplay" :muted="muted" :src="computed_src" />`,
   props: {
     controls: Boolean,
     autoplay: Boolean,
     muted: Boolean,
     src: String,
   },
+  data: function () {
+    return {
+      computed_src: undefined,
+    };
+  },
+  mounted() {
+    setTimeout(() => this.compute_src(), 0); // NOTE: wait for window.path_prefix to be set in app.mounted()
+  },
+  updated() {
+    this.compute_src();
+  },
+  methods: {
+    compute_src() {
+      this.computed_src = (this.src.startsWith("/") ? window.path_prefix : "") + this.src;
+    },
+    seek(seconds) {
+      this.$el.currentTime = seconds;
+    },
+  },
 };

+ 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())

+ 10 - 8
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
@@ -268,18 +268,20 @@ class KeyEventArguments(EventArguments):
     modifiers: KeyboardModifiers
 
 
-def handle_event(handler: Optional[Callable],
+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.

+ 1 - 1
nicegui/functions/notify.py

@@ -16,7 +16,7 @@ def notify(message: Any, *,
            type: Optional[Literal['positive', 'negative', 'warning', 'info', 'ongoing']] = None,
            color: Optional[str] = None,
            multi_line: bool = False,
-           **kwargs,
+           **kwargs: Any,
            ) -> None:
     """Notification
 

部分文件因为文件数量过多而无法显示