Browse Source

Merge branch 'main' into changeable_native_window

Falko Schindler 2 years ago
parent
commit
4057c6c693

+ 3 - 3
CITATION.cff

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

+ 7 - 6
examples/authentication/main.py

@@ -1,11 +1,12 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
-'''This is only a very simple authentication example which stores session IDs in memory and does not do any password hashing.
+"""This is a very simple authentication example which stores session IDs in memory and does not do any password hashing.
 
 
 Please see the `OAuth2 example at FastAPI <https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/>`_  or
 Please see the `OAuth2 example at FastAPI <https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/>`_  or
-use the great `Authlib package <https://docs.authlib.org/en/v0.13/client/starlette.html#using-fastapi>`_ to implement a real authentication system.
+use the great `Authlib package <https://docs.authlib.org/en/v0.13/client/starlette.html#using-fastapi>`_
+to implement a real authentication system.
 
 
 Here we just demonstrate the NiceGUI integration.
 Here we just demonstrate the NiceGUI integration.
-'''
+"""
 
 
 import os
 import os
 import uuid
 import uuid
@@ -38,7 +39,7 @@ def main_page(request: Request) -> None:
         ui.label(f'Hello {session["username"]}!').classes('text-2xl')
         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)
         # 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
         # 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.button(on_click=lambda: ui.open('/logout')).props('outline round icon=logout')
 
 
 
 
 @ui.page('/login')
 @ui.page('/login')
@@ -55,12 +56,12 @@ def login(request: Request) -> None:
     request.session['id'] = str(uuid.uuid4())  # NOTE this stores a new session ID in the cookie of the client
     request.session['id'] = str(uuid.uuid4())  # NOTE this stores a new session ID in the cookie of the client
     with ui.card().classes('absolute-center'):
     with ui.card().classes('absolute-center'):
         username = ui.input('Username').on('keydown.enter', try_login)
         username = ui.input('Username').on('keydown.enter', try_login)
-        password = ui.input('Password').props('type=password').on('keydown.enter', try_login)
+        password = ui.input('Password').on('keydown.enter', try_login).props('type=password')
         ui.button('Log in', on_click=try_login)
         ui.button('Log in', on_click=try_login)
 
 
 
 
 @ui.page('/logout')
 @ui.page('/logout')
-def logout(request: Request) -> None:
+def logout(request: Request) -> RedirectResponse:
     if is_authenticated(request):
     if is_authenticated(request):
         session_info.pop(request.session['id'])
         session_info.pop(request.session['id'])
         request.session['id'] = None
         request.session['id'] = None

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

+ 5 - 3
fetch_tailwind.py

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

+ 1 - 0
main.py

@@ -279,6 +279,7 @@ async def index_page(client: Client) -> None:
             example_link('Chat with AI', 'a simple chat app with AI')
             example_link('Chat with AI', 'a simple chat app with AI')
             example_link('SQLite Database', 'CRUD operations on a SQLite database')
             example_link('SQLite Database', 'CRUD operations on a SQLite database')
             example_link('Pandas DataFrame', 'displays an editable [pandas](https://pandas.pydata.org) DataFrame')
             example_link('Pandas DataFrame', 'displays an editable [pandas](https://pandas.pydata.org) DataFrame')
+            example_link('Lightbox', 'A thumbnail gallery where each image can be clicked to enlarge')
 
 
     with ui.row().classes('bg-primary w-full min-h-screen mt-16'):
     with ui.row().classes('bg-primary w-full min-h-screen mt-16'):
         link_target('why')
         link_target('why')

+ 2 - 0
mypy.ini

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

+ 1 - 1
nicegui/__init__.py

@@ -3,7 +3,7 @@ try:
 except ModuleNotFoundError:
 except ModuleNotFoundError:
     import importlib_metadata
     import importlib_metadata
 
 
-__version__ = importlib_metadata.version('nicegui')
+__version__: str = importlib_metadata.version('nicegui')
 
 
 from . import elements, globals, ui
 from . import elements, globals, ui
 from .client import Client
 from .client import Client

+ 1 - 1
nicegui/app.py

@@ -43,7 +43,7 @@ class App(FastAPI):
         """
         """
         globals.shutdown_handlers.append(handler)
         globals.shutdown_handlers.append(handler)
 
 
-    def on_exception(self, handler: Union[Callable, Awaitable]) -> None:
+    def on_exception(self, handler: Callable) -> None:
         """Called when an exception occurs.
         """Called when an exception occurs.
 
 
         The callback has an optional parameter of `Exception`.
         The callback has an optional parameter of `Exception`.

+ 3 - 1
nicegui/background_tasks.py

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

+ 1 - 1
nicegui/binding.py

@@ -25,7 +25,7 @@ def set_attribute(obj: Union[object, Dict], name: str, value: Any) -> None:
         setattr(obj, name, value)
         setattr(obj, name, value)
 
 
 
 
-async def loop():
+async def loop() -> None:
     while True:
     while True:
         visited: Set[Tuple[int, str]] = set()
         visited: Set[Tuple[int, str]] = set()
         t = time.time()
         t = time.time()

+ 2 - 2
nicegui/client.py

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

+ 3 - 2
nicegui/element.py

@@ -18,7 +18,7 @@ from .tailwind import Tailwind
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from .client import Client
     from .client import Client
 
 
-PROPS_PATTERN = re.compile(r'([\w\-]+)(?:=(?:("[^"\\]*(?:\\.[^"\\]*)*")|([\w\-.%:\/]+)))?(?:$|\s)')
+PROPS_PATTERN = re.compile(r'([:\w\-]+)(?:=(?:("[^"\\]*(?:\\.[^"\\]*)*")|([\w\-.%:\/]+)))?(?:$|\s)')
 
 
 
 
 class Element(Visibility):
 class Element(Visibility):
@@ -80,7 +80,7 @@ class Element(Visibility):
             for child in slot:
             for child in slot:
                 yield child
                 yield child
 
 
-    def _collect_slot_dict(self) -> Dict[str, List[int]]:
+    def _collect_slot_dict(self) -> Dict[str, Any]:
         return {
         return {
             name: {'template': slot.template, 'ids': [child.id for child in slot]}
             name: {'template': slot.template, 'ids': [child.id for child in slot]}
             for name, slot in self.slots.items()
             for name, slot in self.slots.items()
@@ -276,6 +276,7 @@ class Element(Visibility):
         :param target_container: container to move the element to (default: the parent container)
         :param target_container: container to move the element to (default: the parent container)
         :param target_index: index within the target slot (default: append to the end)
         :param target_index: index within the target slot (default: append to the end)
         """
         """
+        assert self.parent_slot is not None
         self.parent_slot.children.remove(self)
         self.parent_slot.children.remove(self)
         self.parent_slot.parent.update()
         self.parent_slot.parent.update()
         target_container = target_container or self.parent_slot.parent
         target_container = target_container or self.parent_slot.parent

+ 3 - 2
nicegui/elements/aggrid.py

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

+ 2 - 1
nicegui/elements/input.py

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

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

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

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

@@ -1,4 +1,4 @@
-from typing import TYPE_CHECKING, Any, Callable
+from typing import TYPE_CHECKING, Any, Callable, cast
 
 
 from typing_extensions import Self
 from typing_extensions import Self
 
 
@@ -79,11 +79,12 @@ class Visibility:
         """
         """
         self.visible = visible
         self.visible = visible
 
 
-    def on_visibility_change(self: 'Element', visible: str) -> None:
+    def on_visibility_change(self, visible: str) -> None:
         """Called when the visibility of this element changes.
         """Called when the visibility of this element changes.
 
 
         :param visible: Whether the element should be visible.
         :param visible: Whether the element should be visible.
         """
         """
+        self = cast('Element', self)
         if visible and 'hidden' in self._classes:
         if visible and 'hidden' in self._classes:
             self._classes.remove('hidden')
             self._classes.remove('hidden')
             self.update()
             self.update()

+ 4 - 4
nicegui/elements/scene_object3d.py

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

+ 7 - 5
nicegui/elements/upload.py

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

+ 2 - 2
nicegui/event_listener.py

@@ -1,6 +1,6 @@
 import uuid
 import uuid
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
-from typing import Any, Callable, Dict, List
+from typing import Any, Callable, Dict, List, Optional
 
 
 from .helpers import KWONLY_SLOTS
 from .helpers import KWONLY_SLOTS
 
 
@@ -10,7 +10,7 @@ class EventListener:
     id: str = field(init=False)
     id: str = field(init=False)
     element_id: int
     element_id: int
     type: str
     type: str
-    args: List[str]
+    args: Optional[List[str]]
     handler: Callable
     handler: Callable
     throttle: float
     throttle: float
     leading_events: bool
     leading_events: bool

+ 2 - 2
nicegui/events.py

@@ -1,5 +1,5 @@
 from dataclasses import dataclass
 from dataclasses import dataclass
-from inspect import signature
+from inspect import Parameter, signature
 from typing import TYPE_CHECKING, Any, BinaryIO, Callable, Dict, List, Optional, Union
 from typing import TYPE_CHECKING, Any, BinaryIO, Callable, Dict, List, Optional, Union
 
 
 from . import background_tasks, globals
 from . import background_tasks, globals
@@ -274,7 +274,7 @@ def handle_event(handler: Optional[Callable[..., Any]],
     try:
     try:
         if handler is None:
         if handler is None:
             return
             return
-        no_arguments = not signature(handler).parameters
+        no_arguments = not any(p.default is Parameter.empty for p in signature(handler).parameters.values())
         sender = arguments.sender if isinstance(arguments, EventArguments) else sender
         sender = arguments.sender if isinstance(arguments, EventArguments) else sender
         assert sender is not None and sender.parent_slot is not None
         assert sender is not None and sender.parent_slot is not None
         with sender.parent_slot:
         with sender.parent_slot:

+ 2 - 2
nicegui/functions/javascript.py

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

+ 40 - 31
nicegui/functions/refreshable.py

@@ -1,15 +1,41 @@
-from typing import Any, Callable, Dict, List, Tuple
+from dataclasses import dataclass
+from typing import Any, Awaitable, Callable, Dict, List, Tuple, Union
 
 
 from typing_extensions import Self
 from typing_extensions import Self
 
 
 from .. import background_tasks, globals
 from .. import background_tasks, globals
 from ..dependencies import register_component
 from ..dependencies import register_component
 from ..element import Element
 from ..element import Element
-from ..helpers import is_coroutine
+from ..helpers import KWONLY_SLOTS, is_coroutine
 
 
 register_component('refreshable', __file__, 'refreshable.js')
 register_component('refreshable', __file__, 'refreshable.js')
 
 
 
 
+@dataclass(**KWONLY_SLOTS)
+class RefreshableTarget:
+    container: Element
+    instance: Any
+    args: Tuple[Any, ...]
+    kwargs: Dict[str, Any]
+
+    def run(self, func: Callable[..., Any]) -> Union[None, Awaitable]:
+        if is_coroutine(func):
+            async def wait_for_result() -> None:
+                with self.container:
+                    if self.instance is None:
+                        await func(*self.args, **self.kwargs)
+                    else:
+                        await func(self.instance, *self.args, **self.kwargs)
+            return wait_for_result()
+        else:
+            with self.container:
+                if self.instance is None:
+                    func(*self.args, **self.kwargs)
+                else:
+                    func(self.instance, *self.args, **self.kwargs)
+            return None  # required by mypy
+
+
 class refreshable:
 class refreshable:
 
 
     def __init__(self, func: Callable[..., Any]) -> None:
     def __init__(self, func: Callable[..., Any]) -> None:
@@ -20,48 +46,31 @@ class refreshable:
         """
         """
         self.func = func
         self.func = func
         self.instance = None
         self.instance = None
-        self.containers: List[Tuple[Element, List[Any], Dict[str, Any]]] = []
+        self.targets: List[RefreshableTarget] = []
 
 
     def __get__(self, instance, _) -> Self:
     def __get__(self, instance, _) -> Self:
         self.instance = instance
         self.instance = instance
         return self
         return self
 
 
-    def __call__(self, *args: Any, **kwargs: Any) -> None:
+    def __call__(self, *args: Any, **kwargs: Any) -> Union[None, Awaitable]:
         self.prune()
         self.prune()
-        with Element('refreshable') as container:
-            self.containers.append((container, args, kwargs))
-        return self._run_in_container(container, *args, **kwargs)
+        target = RefreshableTarget(container=Element('refreshable'), instance=self.instance, args=args, kwargs=kwargs)
+        self.targets.append(target)
+        return target.run(self.func)
 
 
     def refresh(self) -> None:
     def refresh(self) -> None:
         self.prune()
         self.prune()
-        for container, args, kwargs in self.containers:
-            container.clear()
-            result = self._run_in_container(container, *args, **kwargs)
+        for target in self.targets:
+            if target.instance != self.instance:
+                continue
+            target.container.clear()
+            result = target.run(self.func)
             if is_coroutine(self.func):
             if is_coroutine(self.func):
+                assert result is not None
                 if globals.loop and globals.loop.is_running():
                 if globals.loop and globals.loop.is_running():
                     background_tasks.create(result)
                     background_tasks.create(result)
                 else:
                 else:
                     globals.app.on_startup(result)
                     globals.app.on_startup(result)
 
 
     def prune(self) -> None:
     def prune(self) -> None:
-        self.containers = [
-            (container, args, kwargs)
-            for container, args, kwargs in self.containers
-            if container.client.id in globals.clients
-        ]
-
-    def _run_in_container(self, container: Element, *args: Any, **kwargs: Any) -> None:
-        if is_coroutine(self.func):
-            async def wait_for_result() -> None:
-                with container:
-                    if self.instance is None:
-                        await self.func(*args, **kwargs)
-                    else:
-                        await self.func(self.instance, *args, **kwargs)
-            return wait_for_result()
-        else:
-            with container:
-                if self.instance is None:
-                    self.func(*args, **kwargs)
-                else:
-                    self.func(self.instance, *args, **kwargs)
+        self.targets = [target for target in self.targets if target.container.client.id in globals.clients]

+ 8 - 3
nicegui/functions/timer.py

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

+ 2 - 2
nicegui/globals.py

@@ -3,7 +3,7 @@ import inspect
 import logging
 import logging
 from contextlib import contextmanager
 from contextlib import contextmanager
 from enum import Enum
 from enum import Enum
-from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Optional, Union
+from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterator, List, Optional, Union
 
 
 from socketio import AsyncServer
 from socketio import AsyncServer
 from uvicorn import Server
 from uvicorn import Server
@@ -86,7 +86,7 @@ def get_client() -> 'Client':
 
 
 
 
 @contextmanager
 @contextmanager
-def socket_id(id: str) -> None:
+def socket_id(id: str) -> Iterator[None]:
     global _socket_id
     global _socket_id
     _socket_id = id
     _socket_id = id
     yield
     yield

+ 1 - 1
nicegui/nicegui.py

@@ -27,7 +27,7 @@ from .page import page
 globals.app = app = App(default_response_class=NiceGUIJSONResponse)
 globals.app = app = App(default_response_class=NiceGUIJSONResponse)
 # NOTE we use custom json module which wraps orjson
 # NOTE we use custom json module which wraps orjson
 socket_manager = SocketManager(app=app, mount_location='/_nicegui_ws/', json=json)
 socket_manager = SocketManager(app=app, mount_location='/_nicegui_ws/', json=json)
-globals.sio = sio = app.sio
+globals.sio = sio = socket_manager._sio
 
 
 app.add_middleware(GZipMiddleware)
 app.add_middleware(GZipMiddleware)
 static_files = StaticFiles(
 static_files = StaticFiles(

+ 3 - 3
nicegui/outbox.py

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

+ 5 - 3
nicegui/tailwind.py

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

+ 6 - 0
nicegui/templates/index.html

@@ -72,6 +72,12 @@
           style: Object.entries(element.style).reduce((str, [p, val]) => `${str}${p}:${val};`, '') || undefined,
           style: Object.entries(element.style).reduce((str, [p, val]) => `${str}${p}:${val};`, '') || undefined,
           ...element.props,
           ...element.props,
         };
         };
+        Object.entries(props).forEach(([key, value]) => {
+          if (key.startsWith(':')) {
+            props[key.substring(1)] = eval(value);
+            delete props[key];
+          }
+        });
         element.events.forEach((event) => {
         element.events.forEach((event) => {
           let event_name = 'on' + event.type[0].toLocaleUpperCase() + event.type.substring(1);
           let event_name = 'on' + event.type[0].toLocaleUpperCase() + event.type.substring(1);
           event.specials.forEach(s => event_name += s[0].toLocaleUpperCase() + s.substring(1));
           event.specials.forEach(s => event_name += s[0].toLocaleUpperCase() + s.substring(1));

+ 14 - 0
tests/test_date.py

@@ -50,3 +50,17 @@ def test_date_with_range_and_multi_selection(screen: Screen):
     screen.click('25')
     screen.click('25')
     screen.click('28')
     screen.click('28')
     screen.should_contain('8 days')
     screen.should_contain('8 days')
+
+
+def test_date_with_filter(screen: Screen):
+    d = ui.date().props('''default-year-month=2023/01 :options="date => date <= '2023/01/15'"''')
+    ui.label().bind_text_from(d, 'value')
+
+    screen.open('/')
+    screen.click('14')
+    screen.should_contain('2023-01-14')
+    screen.click('15')
+    screen.should_contain('2023-01-15')
+    screen.click('16')
+    screen.wait(0.5)
+    screen.should_not_contain('2023-01-16')

+ 37 - 0
tests/test_refreshable.py

@@ -61,3 +61,40 @@ async def test_async_refreshable(screen: Screen) -> None:
     numbers.clear()
     numbers.clear()
     screen.click('Refresh')
     screen.click('Refresh')
     screen.should_contain('[]')
     screen.should_contain('[]')
+
+
+def test_multiple_targets(screen: Screen) -> None:
+    count = 0
+
+    class MyClass:
+
+        def __init__(self, name: str) -> None:
+            self.name = name
+            self.state = 1
+
+        @ui.refreshable
+        def create_ui(self) -> None:
+            nonlocal count
+            count += 1
+            ui.label(f'{self.name} = {self.state} ({count})')
+            ui.button(f'increment {self.name}', on_click=self.increment)
+
+        def increment(self) -> None:
+            self.state += 1
+            self.create_ui.refresh()
+
+    a = MyClass('A')
+    a.create_ui()
+
+    b = MyClass('B')
+    b.create_ui()
+
+    screen.open('/')
+    screen.should_contain('A = 1 (1)')
+    screen.should_contain('B = 1 (2)')
+    screen.click('increment A')
+    screen.should_contain('A = 2 (3)')
+    screen.should_contain('B = 1 (2)')
+    screen.click('increment B')
+    screen.should_contain('A = 2 (3)')
+    screen.should_contain('B = 2 (4)')

+ 3 - 3
tests/test_time.py

@@ -9,12 +9,12 @@ def test_time(screen: Screen):
 
 
     screen.open('/')
     screen.open('/')
     screen.should_contain('01:23')
     screen.should_contain('01:23')
-
+    screen.wait(0.2)
     screen.click('8')
     screen.click('8')
     screen.should_contain('08:23')
     screen.should_contain('08:23')
-
+    screen.wait(0.2)
     screen.click('45')
     screen.click('45')
     screen.should_contain('08:45')
     screen.should_contain('08:45')
-
+    screen.wait(0.2)
     screen.click('PM')
     screen.click('PM')
     screen.should_contain('20:45')
     screen.should_contain('20:45')

+ 1 - 0
website/documentation.py

@@ -207,6 +207,7 @@ def create_full() -> None:
         NiceGUI uses the [Quasar Framework](https://quasar.dev/) version 1.0 and hence has its full design power.
         NiceGUI uses the [Quasar Framework](https://quasar.dev/) version 1.0 and hence has its full design power.
         Each NiceGUI element provides a `props` method whose content is passed [to the Quasar component](https://justpy.io/quasar_tutorial/introduction/#props-of-quasar-components):
         Each NiceGUI element provides a `props` method whose content is passed [to the Quasar component](https://justpy.io/quasar_tutorial/introduction/#props-of-quasar-components):
         Have a look at [the Quasar documentation](https://quasar.dev/vue-components/button#design) for all styling props.
         Have a look at [the Quasar documentation](https://quasar.dev/vue-components/button#design) for all styling props.
+        Props with a leading `:` can contain JavaScript expressions that are evaluated on the client.
         You can also apply [Tailwind CSS](https://tailwindcss.com/) utility classes with the `classes` method.
         You can also apply [Tailwind CSS](https://tailwindcss.com/) utility classes with the `classes` method.
 
 
         If you really need to apply CSS, you can use the `styles` method. Here the delimiter is `;` instead of a blank space.
         If you really need to apply CSS, you can use the `styles` method. Here the delimiter is `;` instead of a blank space.

+ 8 - 0
website/more_documentation/date_documentation.py

@@ -23,3 +23,11 @@ def more() -> None:
                 ui.icon('edit_calendar').on('click', lambda: menu.open()).classes('cursor-pointer')
                 ui.icon('edit_calendar').on('click', lambda: menu.open()).classes('cursor-pointer')
             with ui.menu() as menu:
             with ui.menu() as menu:
                 ui.date().bind_value(date)
                 ui.date().bind_value(date)
+
+    @text_demo('Date filter', '''
+        This demo shows how to filter the dates in a date picker.
+        In order to pass a function to the date picker, we use the `:options` property.
+        The leading `:` tells NiceGUI that the value is a JavaScript expression.
+    ''')
+    def date_filter():
+        ui.date().props('''default-year-month=2023/01 :options="date => date <= '2023/01/15'"''')

+ 7 - 0
website/more_documentation/input_documentation.py

@@ -19,3 +19,10 @@ def more() -> None:
     async def autocomplete_demo():
     async def autocomplete_demo():
         options = ['AutoComplete', 'NiceGUI', 'Awesome']
         options = ['AutoComplete', 'NiceGUI', 'Awesome']
         ui.input(label='Text', placeholder='start typing', autocomplete=options)
         ui.input(label='Text', placeholder='start typing', autocomplete=options)
+
+    @text_demo('Clearable', '''
+        The `clearable` prop from [Quasar](https://quasar.dev/) adds a button to the input that clears the text.    
+    ''')
+    async def clearable():
+        i = ui.input(value='some text').props('clearable')
+        ui.label().bind_text_from(i, 'value')

+ 12 - 0
website/more_documentation/number_documentation.py

@@ -1,7 +1,19 @@
 from nicegui import ui
 from nicegui import ui
 
 
+from ..documentation_tools import text_demo
+
 
 
 def main_demo() -> None:
 def main_demo() -> None:
     ui.number(label='Number', value=3.1415927, format='%.2f',
     ui.number(label='Number', value=3.1415927, format='%.2f',
               on_change=lambda e: result.set_text(f'you entered: {e.value}'))
               on_change=lambda e: result.set_text(f'you entered: {e.value}'))
     result = ui.label()
     result = ui.label()
+
+
+def more() -> None:
+
+    @text_demo('Clearable', '''
+        The `clearable` prop from [Quasar](https://quasar.dev/) adds a button to the input that clears the text.    
+    ''')
+    async def clearable():
+        i = ui.number(value=42).props('clearable')
+        ui.label().bind_text_from(i, 'value')

+ 12 - 0
website/more_documentation/textarea_documentation.py

@@ -1,7 +1,19 @@
 from nicegui import ui
 from nicegui import ui
 
 
+from ..documentation_tools import text_demo
+
 
 
 def main_demo() -> None:
 def main_demo() -> None:
     ui.textarea(label='Text', placeholder='start typing',
     ui.textarea(label='Text', placeholder='start typing',
                 on_change=lambda e: result.set_text('you typed: ' + e.value))
                 on_change=lambda e: result.set_text('you typed: ' + e.value))
     result = ui.label()
     result = ui.label()
+
+
+def more() -> None:
+
+    @text_demo('Clearable', '''
+        The `clearable` prop from [Quasar](https://quasar.dev/) adds a button to the input that clears the text.    
+    ''')
+    async def clearable():
+        i = ui.textarea(value='some text').props('clearable')
+        ui.label().bind_text_from(i, 'value')