Переглянути джерело

Merge commit 'c63828ef2c7b8f464b7fff43784a3f8b7802f6a0' into v1.3
resolved merge conflict in events.py

Rodja Trappe 1 рік тому
батько
коміт
f6543a56e0

+ 13 - 3
.github/workflows/test.yml

@@ -25,7 +25,7 @@ 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 simpy
+          pip install opencv-python opencv-contrib-python-headless httpx replicate langchain openai simpy tortoise-orm
           pip install -r tests/requirements.txt
           # try fix issue with importlib_resources
           pip install importlib-resources
@@ -46,7 +46,7 @@ jobs:
   slack:
     needs:
       - test
-    if: always() # also execute when test fails
+    if: always() # also execute when test fail
     runs-on: ubuntu-latest
     steps:
       - name: Determine if we need to notify
@@ -55,8 +55,18 @@ jobs:
         with:
           needs_context: ${{ toJson(needs) }}
           github_token: ${{ secrets.GITHUB_TOKEN }}
+      - name: Check if secret exists
+        id: check_secret
+        env:
+          SLACK_WEBHOOK: ${{ secrets.SLACK_ROBOTICS_CI_WEBHOOK }}
+        run: |
+          if [[ -z "$SLACK_WEBHOOK" ]]; then
+            echo "slack_webhook_exists=false" >> $GITHUB_ENV
+          else
+            echo "slack_webhook_exists=true" >> $GITHUB_ENV
+          fi
       - name: Slack workflow notification
-        if: steps.should_notify.outputs.should_send_message == 'yes'
+        if: steps.should_notify.outputs.should_send_message == 'yes' && env.slack_webhook_exists == 'true'
         uses: Gamesight/slack-workflow-status@master
         with:
           repo_token: ${{ secrets.GITHUB_TOKEN }}

+ 1 - 0
.gitignore

@@ -9,3 +9,4 @@ tests/media/
 venv
 .idea
 .nicegui/
+*.sqlite*

+ 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.23
-date-released: '2023-06-26'
+version: v1.2.24
+date-released: '2023-06-30'
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.8083457
+doi: 10.5281/zenodo.8098592

+ 6 - 5
examples/script_executor/main.py

@@ -29,11 +29,12 @@ async def run_command(command: str) -> None:
 with ui.dialog() as dialog, ui.card():
     result = ui.markdown()
 
-commands = ['python3 hello.py', 'python3 hello.py NiceGUI', 'python3 slow.py']
-with ui.row():
-    for command in commands:
-        ui.button(command, on_click=lambda command=command: run_command(command)).props('no-caps')
-
+ui.button('python3 hello.py', on_click=lambda: run_command('python3 hello.py')).props('no-caps')
+ui.button('python3 slow.py', on_click=lambda: run_command('python3 slow.py')).props('no-caps')
+with ui.row().classes('items-center'):
+    ui.button('python3 hello.py "<message>"', on_click=lambda: run_command(f'python3 hello.py "{message.value}"')) \
+        .props('no-caps')
+    message = ui.input('message', value='NiceGUI')
 
 # NOTE on windows reload must be disabled to make asyncio.create_subprocess_exec work (see https://github.com/zauberzeug/nicegui/issues/486)
 ui.run(reload=platform.system() != "Windows")

+ 1 - 1
examples/sqlite_database/.gitignore

@@ -1 +1 @@
-*.db
+*.sqlite*

+ 40 - 70
examples/sqlite_database/main.py

@@ -1,79 +1,49 @@
 #!/usr/bin/env python3
-import sqlite3
-from pathlib import Path
-from typing import Any, Dict
+from typing import List
 
-from nicegui import ui
+import models
+from tortoise.contrib.fastapi import register_tortoise
 
-DB_FILE = Path(__file__).parent / 'users.db'
-DB_FILE.touch()
-conn = sqlite3.connect(DB_FILE, check_same_thread=False)
-cursor = conn.cursor()
-cursor.execute('CREATE TABLE IF NOT EXISTS users (id integer primary key AUTOINCREMENT, name text, age integer)')
-conn.commit()
+from nicegui import app, ui
 
-
-@ui.refreshable
-def users_ui() -> None:
-    cursor.execute('SELECT * FROM users')
-    for row in cursor.fetchall():
-        user = {'id': row[0], 'name': row[1], 'age': row[2]}
-        with ui.card():
-            with ui.row().classes('justify-between w-full'):
-                ui.label(user['id'])
-                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')
-
-
-def create() -> None:
-    cursor.execute('INSERT INTO users (name, age) VALUES (?, ?)', (name.value, age.value))
-    conn.commit()
-    ui.notify(f'Created new user {name.value}')
-    name.value = ''
-    age.value = None
-    users_ui.refresh()
-
-
-def update() -> None:
-    query = 'UPDATE users SET name=?, age=? WHERE id=?'
-    cursor.execute(query, (dialog_name.value, dialog_age.value, dialog_id))
-    conn.commit()
-    ui.notify(f'Updated user {dialog_name.value}')
-    dialog.close()
-    users_ui.refresh()
+register_tortoise(
+    app,
+    db_url='sqlite://db.sqlite3',
+    modules={'models': ['models']},  # tortoise will look for models in this main module
+    generate_schemas=True,  # in production you should use version control migrations instead
+)
 
 
-def delete(user: Dict[str, Any]) -> None:
-    cursor.execute('DELETE from users WHERE id=?', (user['id'],))
-    conn.commit()
-    ui.notify(f'Deleted user {user["name"]}')
-    users_ui.refresh()
-
-
-def open_dialog(user: Dict[str, Any]) -> None:
-    global dialog_id
-    dialog_id = user['id']
-    dialog_name.value = user['name']
-    dialog_age.value = user['age']
-    dialog.open()
-
-
-name = ui.input(label='Name')
-age = ui.number(label='Age', format='%.0f')
-ui.button('Add new user', on_click=create)
-
-users_ui()
+@ui.refreshable
+async def list_of_users() -> None:
+    async def delete(user: models.User) -> None:
+        await user.delete()
+        list_of_users.refresh()
 
-with ui.dialog() as dialog:
-    with ui.card():
-        dialog_id = None
-        dialog_name = ui.input('Name')
-        dialog_age = ui.number('Age', format='%.0f')
-        with ui.row():
-            ui.button('Save', on_click=update)
-            ui.button('Close', on_click=dialog.close).props('outline')
+    users: List[models.User] = await models.User.all()
+    for user in reversed(users):
+        with ui.card():
+            with ui.row().classes('items-center'):
+                ui.input('Name', on_change=user.save) \
+                    .bind_value(user, 'name').on('blur', list_of_users.refresh)
+                ui.number('Age', on_change=user.save, format='%.0f') \
+                    .bind_value(user, 'age').on('blur', list_of_users.refresh).classes('w-20')
+                ui.button(icon='delete', on_click=lambda u=user: delete(u)).props('flat')
+
+
+@ui.page('/')
+async def index():
+    async def create() -> None:
+        await models.User.create(name=name.value, age=age.value or 0)
+        name.value = ''
+        age.value = None
+        list_of_users.refresh()
+
+    with ui.column().classes('mx-auto'):
+        with ui.row().classes('w-full items-center px-4'):
+            name = ui.input(label='Name')
+            age = ui.number(label='Age', format='%.0f').classes('w-20')
+            ui.button(on_click=create, icon='add').props('flat').classes('ml-auto')
+        await list_of_users()
 
 ui.run()

+ 7 - 0
examples/sqlite_database/models.py

@@ -0,0 +1,7 @@
+from tortoise import fields, models
+
+
+class User(models.Model):
+    id = fields.IntField(pk=True)
+    name = fields.CharField(max_length=255)
+    age = fields.IntField()

+ 1 - 0
examples/sqlite_database/requirements.txt

@@ -0,0 +1 @@
+tortoise-orm

+ 1 - 1
main.py

@@ -281,7 +281,7 @@ async def index_page(client: Client) -> None:
             example_link('Single Page App', 'navigate without reloading the page')
             example_link('Chat App', 'a simple chat app')
             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 with async-support through Tortoise ORM')
             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')
             example_link('ROS2', 'Using NiceGUI as web interface for a ROS2 robot')

+ 2 - 0
nicegui/background_tasks.py

@@ -36,6 +36,8 @@ def create_lazy(coroutine: Awaitable[T], *, name: str) -> None:
     If a third task with the same name is created while the first one is still running, the second one is discarded.
     """
     if name in lazy_tasks_running:
+        if name in lazy_tasks_waiting:
+            asyncio.Task(lazy_tasks_waiting[name]).cancel()
         lazy_tasks_waiting[name] = coroutine
         return
 

+ 1 - 1
nicegui/client.py

@@ -36,7 +36,7 @@ class Client:
         self.shared = shared
 
         with Element('q-layout', _client=self).props('view="HHH LpR FFF"').classes('nicegui-layout') as self.layout:
-            with Element('q-page-container'):
+            with Element('q-page-container') as self.page_container:
                 with Element('q-page'):
                     self.content = Element('div').classes('nicegui-content')
 

+ 1 - 1
nicegui/elements/icon.py

@@ -15,7 +15,7 @@ class Icon(TextColorElement):
 
         This element is based on Quasar's `QIcon <https://quasar.dev/vue-components/icon>`_ component.
 
-        `Here <https://material.io/icons/>`_ is a reference of possible names.
+        `Here <https://fonts.google.com/icons>`_ is a reference of possible names.
 
         :param name: name of the icon
         :param size: size in CSS units, including unit name or standard size name (xs|sm|md|lg|xl), examples: 16px, 2rem

+ 9 - 5
nicegui/events.py

@@ -284,12 +284,16 @@ def handle_event(handler: Optional[Callable[..., Any]], arguments: EventArgument
     if handler is None:
         return
     try:
-        no_arguments = not any(p.default is Parameter.empty for p in signature(handler).parameters.values())
-        assert arguments.sender.parent_slot is not None
-        if arguments.sender.is_ignoring_events:
+        expects_arguments = any(p.default is Parameter.empty and
+                                p.kind is not Parameter.VAR_POSITIONAL and
+                                p.kind is not Parameter.VAR_KEYWORD
+                                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 arguments.sender.parent_slot:
-            result = handler() if no_arguments else handler(arguments)
+        with sender.parent_slot:
+            result = handler(arguments) if expects_arguments else handler()
         if isinstance(result, Awaitable):
             async def wait_for_result():
                 with arguments.sender.parent_slot:

+ 3 - 1
nicegui/functions/refreshable.py

@@ -61,12 +61,14 @@ class refreshable:
         self.targets.append(target)
         return target.run(self.func)
 
-    def refresh(self) -> None:
+    def refresh(self, *args: Any, **kwargs: Any) -> None:
         self.prune()
         for target in self.targets:
             if target.instance != self.instance:
                 continue
             target.container.clear()
+            target.args = args or target.args
+            target.kwargs.update(kwargs)
             result = target.run(self.func)
             if is_coroutine_function(self.func):
                 assert result is not None

+ 2 - 3
nicegui/functions/timer.py

@@ -1,10 +1,9 @@
 import asyncio
 import time
-from typing import Any, Callable, Optional
+from typing import Any, Awaitable, Callable, Optional
 
 from .. import background_tasks, globals
 from ..binding import BindableProperty
-from ..helpers import is_coroutine_function
 from ..slot import Slot
 
 
@@ -89,7 +88,7 @@ class Timer:
         try:
             assert self.callback is not None
             result = self.callback()
-            if is_coroutine_function(self.callback):
+            if isinstance(result, Awaitable):
                 await result
         except Exception as e:
             globals.handle_exception(e)

+ 22 - 0
nicegui/page_layout.py

@@ -29,6 +29,9 @@ class Header(ValueElement):
                  elevated: bool = False) -> None:
         '''Header
 
+        Note: The header is automatically placed above other layout elements in the DOM to improve accessibility.
+        To change the order, use the `move` method.
+
         :param value: whether the header is already opened (default: `True`)
         :param fixed: whether the header should be fixed to the top of the page (default: `True`)
         :param bordered: whether the header should have a border (default: `False`)
@@ -43,6 +46,8 @@ class Header(ValueElement):
         code[1] = 'H' if fixed else 'h'
         self.client.layout._props['view'] = ''.join(code)
 
+        self.move(target_index=0)
+
     def toggle(self):
         '''Toggle the header'''
         self.value = not self.value
@@ -68,6 +73,9 @@ class Drawer(Element):
                  bottom_corner: bool = False) -> None:
         '''Drawer
 
+        Note: Depending on the side, the drawer is automatically placed above or below the main page container in the DOM to improve accessibility.
+        To change the order, use the `move` method.
+
         :param side: side of the page where the drawer should be placed (`left` or `right`)
         :param value: whether the drawer is already opened (default: `None`, i.e. if layout width is above threshold)
         :param fixed: whether the drawer is fixed or scrolls with the content (default: `True`)
@@ -92,6 +100,9 @@ class Drawer(Element):
         code[8 if side == 'left' else 10] = side[0].lower() if bottom_corner else 'f'
         self.client.layout._props['view'] = ''.join(code)
 
+        page_container_index = self.client.layout.default_slot.children.index(self.client.page_container)
+        self.move(target_index=page_container_index if side == 'left' else page_container_index + 1)
+
     def toggle(self) -> None:
         '''Toggle the drawer'''
         self.run_method('toggle')
@@ -116,6 +127,9 @@ class LeftDrawer(Drawer):
                  bottom_corner: bool = False) -> None:
         '''Left drawer
 
+        Note: The left drawer is automatically placed above the main page container in the DOM to improve accessibility.
+        To change the order, use the `move` method.
+
         :param value: whether the drawer is already opened (default: `None`, i.e. if layout width is above threshold)
         :param fixed: whether the drawer is fixed or scrolls with the content (default: `True`)
         :param bordered: whether the drawer should have a border (default: `False`)
@@ -143,6 +157,9 @@ class RightDrawer(Drawer):
                  bottom_corner: bool = False) -> None:
         '''Right drawer
 
+        Note: The right drawer is automatically placed below the main page container in the DOM to improve accessibility.
+        To change the order, use the `move` method.
+
         :param value: whether the drawer is already opened (default: `None`, i.e. if layout width is above threshold)
         :param fixed: whether the drawer is fixed or scrolls with the content (default: `True`)
         :param bordered: whether the drawer should have a border (default: `False`)
@@ -168,6 +185,9 @@ class Footer(ValueElement):
                  elevated: bool = False) -> None:
         '''Footer
 
+        Note: The footer is automatically placed below other layout elements in the DOM to improve accessibility.
+        To change the order, use the `move` method.
+
         :param value: whether the footer is already opened (default: `True`)
         :param fixed: whether the footer is fixed or scrolls with the content (default: `True`)
         :param bordered: whether the footer should have a border (default: `False`)
@@ -182,6 +202,8 @@ class Footer(ValueElement):
         code[9] = 'F' if fixed else 'f'
         self.client.layout._props['view'] = ''.join(code)
 
+        self.move(target_index=-1)
+
     def toggle(self) -> None:
         '''Toggle the footer'''
         self.value = not self.value

+ 140 - 3
poetry.lock

@@ -1,9 +1,10 @@
-# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry and should not be changed by hand.
 
 [[package]]
 name = "aiofiles"
 version = "23.1.0"
 description = "File support for asyncio."
+category = "main"
 optional = false
 python-versions = ">=3.7,<4.0"
 files = [
@@ -15,6 +16,7 @@ files = [
 name = "anyio"
 version = "3.7.0"
 description = "High level compatibility layer for multiple asynchronous event loop implementations"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -37,6 +39,7 @@ trio = ["trio (<0.22)"]
 name = "asttokens"
 version = "2.2.1"
 description = "Annotate AST trees with source code positions"
+category = "dev"
 optional = false
 python-versions = "*"
 files = [
@@ -54,6 +57,7 @@ test = ["astroid", "pytest"]
 name = "async-generator"
 version = "1.10"
 description = "Async generators and context managers for Python 3.5+"
+category = "dev"
 optional = false
 python-versions = ">=3.5"
 files = [
@@ -65,6 +69,7 @@ files = [
 name = "atomicwrites"
 version = "1.4.1"
 description = "Atomic file writes."
+category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 files = [
@@ -75,6 +80,7 @@ files = [
 name = "attrs"
 version = "23.1.0"
 description = "Classes Without Boilerplate"
+category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -96,6 +102,7 @@ tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pyte
 name = "autopep8"
 version = "1.7.0"
 description = "A tool that automatically formats Python code to conform to the PEP 8 style guide"
+category = "dev"
 optional = false
 python-versions = "*"
 files = [
@@ -111,6 +118,7 @@ toml = "*"
 name = "bidict"
 version = "0.22.1"
 description = "The bidirectional mapping library for Python."
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -127,6 +135,7 @@ test = ["hypothesis", "pytest", "pytest-benchmark[histogram]", "pytest-cov", "py
 name = "bottle"
 version = "0.12.25"
 description = "Fast and simple WSGI-framework for small web-applications."
+category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -138,6 +147,7 @@ files = [
 name = "certifi"
 version = "2023.5.7"
 description = "Python package for providing Mozilla's CA Bundle."
+category = "dev"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -149,6 +159,7 @@ files = [
 name = "cffi"
 version = "1.15.1"
 description = "Foreign Function Interface for Python calling C code."
+category = "dev"
 optional = false
 python-versions = "*"
 files = [
@@ -225,6 +236,7 @@ pycparser = "*"
 name = "charset-normalizer"
 version = "3.1.0"
 description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
+category = "dev"
 optional = false
 python-versions = ">=3.7.0"
 files = [
@@ -309,6 +321,7 @@ files = [
 name = "click"
 version = "8.1.3"
 description = "Composable command line interface toolkit"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -324,6 +337,7 @@ importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
 name = "colorama"
 version = "0.4.6"
 description = "Cross-platform colored terminal text."
+category = "main"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
 files = [
@@ -335,6 +349,7 @@ files = [
 name = "contourpy"
 version = "1.0.7"
 description = "Python library for calculating contours of 2D quadrilateral grids"
+category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -409,6 +424,7 @@ test-no-images = ["pytest"]
 name = "cycler"
 version = "0.11.0"
 description = "Composable style cycles"
+category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -420,6 +436,7 @@ files = [
 name = "debugpy"
 version = "1.6.7"
 description = "An implementation of the Debug Adapter Protocol for Python"
+category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -447,6 +464,7 @@ files = [
 name = "docutils"
 version = "0.19"
 description = "Docutils -- Python Documentation Utilities"
+category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -458,6 +476,7 @@ files = [
 name = "exceptiongroup"
 version = "1.1.1"
 description = "Backport of PEP 654 (exception groups)"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -472,6 +491,7 @@ test = ["pytest (>=6)"]
 name = "executing"
 version = "1.2.0"
 description = "Get the currently executing AST node of a frame, and other information"
+category = "dev"
 optional = false
 python-versions = "*"
 files = [
@@ -486,6 +506,7 @@ tests = ["asttokens", "littleutils", "pytest", "rich"]
 name = "fastapi"
 version = "0.95.2"
 description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -507,6 +528,7 @@ test = ["anyio[trio] (>=3.2.1,<4.0.0)", "black (==23.1.0)", "coverage[toml] (>=6
 name = "fastapi-socketio"
 version = "0.0.10"
 description = "Easily integrate socket.io with your FastAPI app."
+category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -525,6 +547,7 @@ test = ["pytest"]
 name = "fonttools"
 version = "4.38.0"
 description = "Tools to manipulate font files"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -550,6 +573,7 @@ woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"]
 name = "fonttools"
 version = "4.39.4"
 description = "Tools to manipulate font files"
+category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -575,6 +599,7 @@ woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"]
 name = "h11"
 version = "0.14.0"
 description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -589,6 +614,7 @@ typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
 name = "httptools"
 version = "0.5.0"
 description = "A collection of framework independent HTTP protocol utils."
+category = "main"
 optional = false
 python-versions = ">=3.5.0"
 files = [
@@ -642,6 +668,7 @@ test = ["Cython (>=0.29.24,<0.30.0)"]
 name = "icecream"
 version = "2.1.3"
 description = "Never use print() to debug again; inspect variables, expressions, and program execution with a single, simple function call."
+category = "dev"
 optional = false
 python-versions = "*"
 files = [
@@ -659,6 +686,7 @@ pygments = ">=2.2.0"
 name = "idna"
 version = "3.4"
 description = "Internationalized Domain Names in Applications (IDNA)"
+category = "main"
 optional = false
 python-versions = ">=3.5"
 files = [
@@ -670,6 +698,7 @@ files = [
 name = "importlib-metadata"
 version = "6.6.0"
 description = "Read metadata from Python packages"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -690,6 +719,7 @@ testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packag
 name = "iniconfig"
 version = "2.0.0"
 description = "brain-dead simple config-ini parsing"
+category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -701,6 +731,7 @@ files = [
 name = "isort"
 version = "5.11.5"
 description = "A Python utility / library to sort Python imports."
+category = "dev"
 optional = false
 python-versions = ">=3.7.0"
 files = [
@@ -718,6 +749,7 @@ requirements-deprecated-finder = ["pip-api", "pipreqs"]
 name = "itsdangerous"
 version = "2.1.2"
 description = "Safely pass data to untrusted environments and back."
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -729,6 +761,7 @@ files = [
 name = "jinja2"
 version = "3.1.2"
 description = "A very fast and expressive template engine."
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -746,6 +779,7 @@ i18n = ["Babel (>=2.7)"]
 name = "kiwisolver"
 version = "1.4.4"
 description = "A fast implementation of the Cassowary constraint solver"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -826,6 +860,7 @@ typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
 name = "markdown2"
 version = "2.4.8"
 description = "A fast and complete Python implementation of Markdown"
+category = "main"
 optional = false
 python-versions = ">=3.5, <4"
 files = [
@@ -842,6 +877,7 @@ wavedrom = ["wavedrom"]
 name = "markupsafe"
 version = "2.1.2"
 description = "Safely add untrusted strings to HTML/XML markup."
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -901,6 +937,7 @@ files = [
 name = "matplotlib"
 version = "3.5.3"
 description = "Python plotting package"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -955,6 +992,7 @@ python-dateutil = ">=2.7"
 name = "matplotlib"
 version = "3.7.1"
 description = "Python plotting package"
+category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -1016,6 +1054,7 @@ python-dateutil = ">=2.7"
 name = "numpy"
 version = "1.21.1"
 description = "NumPy is the fundamental package for array computing with Python."
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1053,6 +1092,7 @@ files = [
 name = "numpy"
 version = "1.24.3"
 description = "Fundamental package for array computing in Python"
+category = "main"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -1090,6 +1130,7 @@ files = [
 name = "orjson"
 version = "3.9.0"
 description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1145,6 +1186,7 @@ files = [
 name = "outcome"
 version = "1.2.0"
 description = "Capture the outcome of Python function calls."
+category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1159,6 +1201,7 @@ attrs = ">=19.2.0"
 name = "packaging"
 version = "23.1"
 description = "Core utilities for Python packages"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1170,6 +1213,7 @@ files = [
 name = "pandas"
 version = "1.1.5"
 description = "Powerful data structures for data analysis, time series, and statistics"
+category = "dev"
 optional = false
 python-versions = ">=3.6.1"
 files = [
@@ -1211,6 +1255,7 @@ test = ["hypothesis (>=3.58)", "pytest (>=4.0.2)", "pytest-xdist"]
 name = "pandas"
 version = "2.0.2"
 description = "Powerful data structures for data analysis, time series, and statistics"
+category = "dev"
 optional = false
 python-versions = ">=3.8"
 files = [
@@ -1278,6 +1323,7 @@ xml = ["lxml (>=4.6.3)"]
 name = "pillow"
 version = "9.5.0"
 description = "Python Imaging Library (Fork)"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1357,6 +1403,7 @@ tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "pa
 name = "plotly"
 version = "5.14.1"
 description = "An open-source, interactive data visualization library for Python"
+category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -1372,6 +1419,7 @@ tenacity = ">=6.2.0"
 name = "pluggy"
 version = "1.0.0"
 description = "plugin and hook calling mechanisms for python"
+category = "dev"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -1390,6 +1438,7 @@ testing = ["pytest", "pytest-benchmark"]
 name = "proxy-tools"
 version = "0.1.0"
 description = "Proxy Implementation"
+category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1400,6 +1449,7 @@ files = [
 name = "pscript"
 version = "0.7.7"
 description = "Python to JavaScript compiler."
+category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1411,6 +1461,7 @@ files = [
 name = "py"
 version = "1.11.0"
 description = "library with cross-python path, ini-parsing, io, code, log facilities"
+category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 files = [
@@ -1422,6 +1473,7 @@ files = [
 name = "pycodestyle"
 version = "2.10.0"
 description = "Python style guide checker"
+category = "dev"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -1433,6 +1485,7 @@ files = [
 name = "pycparser"
 version = "2.21"
 description = "C parser in Python"
+category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 files = [
@@ -1444,6 +1497,7 @@ files = [
 name = "pydantic"
 version = "1.10.8"
 description = "Data validation and settings management using python type hints"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1496,6 +1550,7 @@ email = ["email-validator (>=1.0.3)"]
 name = "pygments"
 version = "2.15.1"
 description = "Pygments is a syntax highlighting package written in Python."
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1510,6 +1565,7 @@ plugins = ["importlib-metadata"]
 name = "pyobjc-core"
 version = "9.1.1"
 description = "Python<->ObjC Interoperability Module"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1526,6 +1582,7 @@ files = [
 name = "pyobjc-framework-cocoa"
 version = "9.1.1"
 description = "Wrappers for the Cocoa frameworks on macOS"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1545,6 +1602,7 @@ pyobjc-core = ">=9.1.1"
 name = "pyobjc-framework-webkit"
 version = "9.1.1"
 description = "Wrappers for the framework WebKit on macOS"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1562,6 +1620,7 @@ pyobjc-framework-Cocoa = ">=9.1.1"
 name = "pyparsing"
 version = "3.0.9"
 description = "pyparsing module - Classes and methods to define and execute parsing grammars"
+category = "main"
 optional = false
 python-versions = ">=3.6.8"
 files = [
@@ -1576,6 +1635,7 @@ diagrams = ["jinja2", "railroad-diagrams"]
 name = "pysocks"
 version = "1.7.1"
 description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information."
+category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 files = [
@@ -1588,6 +1648,7 @@ files = [
 name = "pytest"
 version = "6.2.5"
 description = "pytest: simple powerful testing with Python"
+category = "dev"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -1613,6 +1674,7 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xm
 name = "pytest-asyncio"
 version = "0.19.0"
 description = "Pytest support for asyncio"
+category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1631,6 +1693,7 @@ testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy
 name = "pytest-base-url"
 version = "2.0.0"
 description = "pytest plugin for URL based testing"
+category = "dev"
 optional = false
 python-versions = ">=3.7,<4.0"
 files = [
@@ -1646,6 +1709,7 @@ requests = ">=2.9"
 name = "pytest-html"
 version = "3.2.0"
 description = "pytest plugin for generating HTML reports"
+category = "dev"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -1662,6 +1726,7 @@ pytest-metadata = "*"
 name = "pytest-metadata"
 version = "2.0.4"
 description = "pytest plugin for test session metadata"
+category = "dev"
 optional = false
 python-versions = ">=3.7,<4.0"
 files = [
@@ -1676,6 +1741,7 @@ pytest = ">=3.0.0,<8.0.0"
 name = "pytest-selenium"
 version = "4.0.1"
 description = "pytest plugin for Selenium"
+category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1700,6 +1766,7 @@ test = ["black (>=22.1.0)", "flake8 (>=4.0.1)", "pre-commit (>=2.17.0)", "pytest
 name = "pytest-variables"
 version = "2.0.0"
 description = "pytest plugin for providing variables to tests/fixtures"
+category = "dev"
 optional = false
 python-versions = ">=3.7,<4.0"
 files = [
@@ -1719,6 +1786,7 @@ yaml = ["PyYAML"]
 name = "python-dateutil"
 version = "2.8.2"
 description = "Extensions to the standard Python datetime module"
+category = "main"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
 files = [
@@ -1733,6 +1801,7 @@ six = ">=1.5"
 name = "python-dotenv"
 version = "0.21.1"
 description = "Read key-value pairs from a .env file and set them as environment variables"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1747,6 +1816,7 @@ cli = ["click (>=5.0)"]
 name = "python-engineio"
 version = "4.4.1"
 description = "Engine.IO server and client for Python"
+category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -1762,6 +1832,7 @@ client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"]
 name = "python-multipart"
 version = "0.0.6"
 description = "A streaming multipart parser for Python"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1776,6 +1847,7 @@ dev = ["atomicwrites (==1.2.1)", "attrs (==19.2.0)", "coverage (==6.5.0)", "hatc
 name = "python-socketio"
 version = "5.8.0"
 description = "Socket.IO server and client for Python"
+category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -1795,6 +1867,7 @@ client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"]
 name = "pythonnet"
 version = "2.5.2"
 description = ".Net and Mono integration for Python"
+category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1818,6 +1891,7 @@ pycparser = "*"
 name = "pytz"
 version = "2023.3"
 description = "World timezone definitions, modern and historical"
+category = "dev"
 optional = false
 python-versions = "*"
 files = [
@@ -1829,6 +1903,7 @@ files = [
 name = "pywebview"
 version = "4.1"
 description = "Build GUI for your Python program with JavaScript, HTML, and CSS."
+category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1856,6 +1931,7 @@ qt = ["PyQt5", "QtPy", "pyqtwebengine"]
 name = "pyyaml"
 version = "6.0"
 description = "YAML parser and emitter for Python"
+category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -1905,6 +1981,7 @@ files = [
 name = "qtpy"
 version = "2.3.1"
 description = "Provides an abstraction layer on top of the various Qt bindings (PyQt5/6 and PySide2/6)."
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1922,6 +1999,7 @@ test = ["pytest (>=6,!=7.0.0,!=7.0.1)", "pytest-cov (>=3.0.0)", "pytest-qt"]
 name = "requests"
 version = "2.31.0"
 description = "Python HTTP for Humans."
+category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1943,6 +2021,7 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
 name = "secure"
 version = "0.3.0"
 description = "A lightweight package that adds security headers for Python web frameworks."
+category = "dev"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -1954,6 +2033,7 @@ files = [
 name = "selenium"
 version = "4.9.1"
 description = ""
+category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1971,6 +2051,7 @@ urllib3 = {version = ">=1.26,<3", extras = ["socks"]}
 name = "six"
 version = "1.16.0"
 description = "Python 2 and 3 compatibility utilities"
+category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
 files = [
@@ -1982,6 +2063,7 @@ files = [
 name = "sniffio"
 version = "1.3.0"
 description = "Sniff out which async library your code is running under"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1993,6 +2075,7 @@ files = [
 name = "sortedcontainers"
 version = "2.4.0"
 description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set"
+category = "dev"
 optional = false
 python-versions = "*"
 files = [
@@ -2004,6 +2087,7 @@ files = [
 name = "starlette"
 version = "0.27.0"
 description = "The little ASGI library that shines."
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -2022,6 +2106,7 @@ full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyam
 name = "tenacity"
 version = "8.2.2"
 description = "Retry code until it succeeds"
+category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -2036,6 +2121,7 @@ doc = ["reno", "sphinx", "tornado (>=4.5)"]
 name = "toml"
 version = "0.10.2"
 description = "Python Library for Tom's Obvious, Minimal Language"
+category = "dev"
 optional = false
 python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
 files = [
@@ -2043,10 +2129,32 @@ files = [
     {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
 ]
 
+[[package]]
+name = "tqdm"
+version = "4.65.0"
+description = "Fast, Extensible Progress Meter"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "tqdm-4.65.0-py3-none-any.whl", hash = "sha256:c4f53a17fe37e132815abceec022631be8ffe1b9381c2e6e30aa70edc99e9671"},
+    {file = "tqdm-4.65.0.tar.gz", hash = "sha256:1871fb68a86b8fb3b59ca4cdd3dcccbc7e6d613eeed31f4c332531977b89beb5"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+
+[package.extras]
+dev = ["py-make (>=0.1.0)", "twine", "wheel"]
+notebook = ["ipywidgets (>=6)"]
+slack = ["slack-sdk"]
+telegram = ["requests"]
+
 [[package]]
 name = "trio"
 version = "0.22.0"
 description = "A friendly Python library for async concurrency and I/O"
+category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -2068,6 +2176,7 @@ sortedcontainers = "*"
 name = "trio-websocket"
 version = "0.10.2"
 description = "WebSocket library for Trio"
+category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -2084,6 +2193,7 @@ wsproto = ">=0.14"
 name = "typing-extensions"
 version = "4.6.3"
 description = "Backported and Experimental Type Hints for Python 3.7+"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -2095,6 +2205,7 @@ files = [
 name = "tzdata"
 version = "2023.3"
 description = "Provider of IANA time zone data"
+category = "dev"
 optional = false
 python-versions = ">=2"
 files = [
@@ -2106,6 +2217,7 @@ files = [
 name = "urllib3"
 version = "2.0.2"
 description = "HTTP library with thread-safe connection pooling, file post, and more."
+category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -2126,6 +2238,7 @@ zstd = ["zstandard (>=0.18.0)"]
 name = "uvicorn"
 version = "0.20.0"
 description = "The lightning-fast ASGI server."
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -2141,7 +2254,7 @@ httptools = {version = ">=0.5.0", optional = true, markers = "extra == \"standar
 python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
 pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""}
 typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
-uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""}
+uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""}
 watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
 websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""}
 
@@ -2152,6 +2265,7 @@ standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)",
 name = "uvloop"
 version = "0.17.0"
 description = "Fast implementation of asyncio event loop on top of libuv"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -2196,6 +2310,7 @@ test = ["Cython (>=0.29.32,<0.30.0)", "aiohttp", "flake8 (>=3.9.2,<3.10.0)", "my
 name = "vbuild"
 version = "0.8.1"
 description = "A simple module to extract html/script/style from a vuejs '.vue' file (can minimize/es2015 compliant js) ... just py2 or py3, NO nodejs !"
+category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
 files = [
@@ -2210,6 +2325,7 @@ pscript = ">=0.7.0,<0.8.0"
 name = "watchfiles"
 version = "0.18.1"
 description = "Simple, modern and high performance file watching and code reload in python."
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -2236,10 +2352,29 @@ files = [
 [package.dependencies]
 anyio = ">=3.0.0"
 
+[[package]]
+name = "webdriver-manager"
+version = "3.8.6"
+description = "Library provides the way to automatically manage drivers for different browsers"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "webdriver_manager-3.8.6-py2.py3-none-any.whl", hash = "sha256:7d3aa8d67bd6c92a5d25f4abd75eea2c6dd24ea6617bff986f502280903a0e2b"},
+    {file = "webdriver_manager-3.8.6.tar.gz", hash = "sha256:ee788d389b8f45222a8a62f6f39b579360a1f87be46dad6da89918354af3ce73"},
+]
+
+[package.dependencies]
+packaging = "*"
+python-dotenv = "*"
+requests = "*"
+tqdm = "*"
+
 [[package]]
 name = "websockets"
 version = "11.0.3"
 description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -2319,6 +2454,7 @@ files = [
 name = "wsproto"
 version = "1.2.0"
 description = "WebSockets state-machine based protocol implementation"
+category = "dev"
 optional = false
 python-versions = ">=3.7.0"
 files = [
@@ -2333,6 +2469,7 @@ h11 = ">=0.9.0,<1"
 name = "zipp"
 version = "3.15.0"
 description = "Backport of pathlib-compatible object wrapper for zip files"
+category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -2347,4 +2484,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.7"
-content-hash = "01dd4e6d62f913d2f5206dcd946dc9804767c4b57b115ef69eb56a73213ae5e4"
+content-hash = "3e7add5739e467bad77fdaba15ee560582a60c65f965d4efb967ab2dcc63af5c"

+ 1 - 0
pyproject.toml

@@ -46,6 +46,7 @@ pandas = [
     { version = "^2.0.0", markers = "python_version >= '3.8'" },
 ]
 secure = "^0.3.0"
+webdriver-manager = "^3.8.6"
 
 [build-system]
 requires = [

+ 14 - 9
tests/conftest.py

@@ -5,6 +5,8 @@ from typing import Dict, Generator
 import icecream
 import pytest
 from selenium import webdriver
+from selenium.webdriver.chrome.service import Service
+from webdriver_manager.chrome import ChromeDriverManager
 
 from nicegui import Client, globals
 from nicegui.page import page
@@ -28,13 +30,6 @@ def capabilities(capabilities: Dict) -> Dict:
     return capabilities
 
 
-@pytest.fixture
-def selenium(selenium: webdriver.Chrome) -> webdriver.Chrome:
-    selenium.implicitly_wait(Screen.IMPLICIT_WAIT)
-    selenium.set_page_load_timeout(4)
-    return selenium
-
-
 @pytest.fixture(autouse=True)
 def reset_globals() -> Generator[None, None, None]:
     for path in {'/'}.union(globals.page_routes.values()):
@@ -56,10 +51,20 @@ def remove_all_screenshots() -> None:
             os.remove(os.path.join(Screen.SCREENSHOT_DIR, name))
 
 
+@pytest.fixture(scope='function')
+def driver(chrome_options: webdriver.ChromeOptions) -> webdriver.Chrome:
+    s = Service(ChromeDriverManager().install())
+    driver = webdriver.Chrome(service=s, options=chrome_options)
+    driver.implicitly_wait(Screen.IMPLICIT_WAIT)
+    driver.set_page_load_timeout(4)
+    yield driver
+    driver.quit()
+
+
 @pytest.fixture
-def screen(selenium: webdriver.Chrome, request: pytest.FixtureRequest, caplog: pytest.LogCaptureFixture) \
+def screen(driver: webdriver.Chrome, request: pytest.FixtureRequest, caplog: pytest.LogCaptureFixture) \
         -> Generator[Screen, None, None]:
-    screen = Screen(selenium, caplog)
+    screen = Screen(driver, caplog)
     yield screen
     if screen.is_open:
         screen.shot(request.node.name)

+ 5 - 4
tests/screen.py

@@ -31,7 +31,7 @@ class Screen:
         self.ui_run_kwargs = {'port': PORT, 'show': False, 'reload': False}
 
     def start_server(self) -> None:
-        '''Start the webserver in a separate thread. This is the equivalent of `ui.run()` in a normal script.'''
+        """Start the webserver in a separate thread. This is the equivalent of `ui.run()` in a normal script."""
         self.server_thread = threading.Thread(target=ui.run, kwargs=self.ui_run_kwargs)
         self.server_thread.start()
 
@@ -45,17 +45,17 @@ class Screen:
             return False
 
     def stop_server(self) -> None:
-        '''Stop the webserver.'''
+        """Stop the webserver."""
         self.close()
         self.caplog.clear()
         globals.server.should_exit = True
         self.server_thread.join()
 
     def open(self, path: str, timeout: float = 3.0) -> None:
-        '''Try to open the page until the server is ready or we time out.
+        """Try to open the page until the server is ready or we time out.
 
         If the server is not yet running, start it.
-        '''
+        """
         if self.server_thread is None:
             self.start_server()
         deadline = time.time() + timeout
@@ -166,6 +166,7 @@ class Screen:
         self.selenium.get_screenshot_as_file(filename)
 
     def assert_py_logger(self, level: str, message: str) -> None:
+        """Assert that the Python logger has received a message with the given level and text."""
         try:
             assert self.caplog.records, 'Expected a log message'
             record = self.caplog.records[0]

+ 28 - 0
tests/test_refreshable.py

@@ -98,3 +98,31 @@ def test_multiple_targets(screen: Screen) -> None:
     screen.click('increment B')
     screen.should_contain('A = 2 (3)')
     screen.should_contain('B = 2 (4)')
+
+
+def test_refresh_with_arguments(screen: Screen):
+    a = 0
+
+    @ui.refreshable
+    def some_ui(*, b: int):
+        ui.label(f'a={a}, b={b}')
+
+    some_ui(b=0)
+    ui.button('Refresh 1', on_click=lambda: some_ui.refresh(b=1))
+    ui.button('Refresh 2', on_click=lambda: some_ui.refresh())
+    ui.button('Refresh 3', on_click=some_ui.refresh)
+
+    screen.open('/')
+    screen.should_contain('a=0, b=0')
+
+    a = 1
+    screen.click('Refresh 1')
+    screen.should_contain('a=1, b=1')
+
+    a = 2
+    screen.click('Refresh 2')
+    screen.should_contain('a=2, b=1')
+
+    a = 3
+    screen.click('Refresh 3')
+    screen.should_contain('a=3, b=1')

+ 17 - 0
tests/test_storage.py

@@ -1,4 +1,5 @@
 import asyncio
+import warnings
 from pathlib import Path
 
 import httpx
@@ -150,3 +151,19 @@ def test_user_and_general_storage_is_persisted(screen: Screen):
     screen.open('/')
     screen.should_contain('user: 1')
     screen.should_contain('general: 4')
+
+
+def test_rapid_storage(screen: Screen):
+    # https://github.com/zauberzeug/nicegui/issues/1099
+    warnings.simplefilter('error')
+
+    ui.button('test', on_click=lambda: (
+        app.storage.general.update(one=1),
+        app.storage.general.update(two=2),
+        app.storage.general.update(three=3),
+    ))
+
+    screen.open('/')
+    screen.click('test')
+    screen.wait(0.5)
+    assert '{"one": 1, "two": 2, "three": 3}' in Path('.nicegui', 'storage_general.json').read_text()

+ 15 - 0
tests/test_timer.py

@@ -1,3 +1,6 @@
+import asyncio
+import warnings
+
 import pytest
 
 from nicegui import ui
@@ -58,3 +61,15 @@ def test_setting_visibility(screen: Screen, once: bool):
     screen.open('/')
     screen.wait(0.5)
     screen.should_not_contain('Some Label')
+
+
+def test_awaiting_coroutine(screen: Screen):
+    warnings.simplefilter('error')
+
+    async def update_user():
+        await asyncio.sleep(0.1)
+
+    ui.timer(1, lambda: update_user())
+
+    screen.open('/')
+    screen.wait(1)

+ 5 - 2
website/more_documentation/colors_documentation.py

@@ -2,5 +2,8 @@ from nicegui import ui
 
 
 def main_demo() -> None:
-    ui.button('Default', on_click=lambda: ui.colors())
-    ui.button('Gray', on_click=lambda: ui.colors(primary='#555'))
+    # ui.button('Default', on_click=lambda: ui.colors())
+    # ui.button('Gray', on_click=lambda: ui.colors(primary='#555'))
+    # END OF DEMO
+    b1 = ui.button('Default', on_click=lambda: [b.classes(replace='!bg-primary') for b in {b1, b2}])
+    b2 = ui.button('Gray', on_click=lambda: [b.classes(replace='!bg-[#555]') for b in {b1, b2}])

+ 18 - 0
website/more_documentation/refreshable_documentation.py

@@ -21,6 +21,24 @@ def main_demo() -> None:
 
 
 def more() -> None:
+    @text_demo('Refreshable UI with parameters', '''
+        Here is a demo of how to use the refreshable decorator to create a UI that can be refreshed with different parameters.
+    ''')
+    def refreshable_with_parameters():
+        from datetime import datetime
+
+        import pytz
+
+        @ui.refreshable
+        def clock_ui(timezone: str):
+            ui.label(f'Current time in {timezone}:')
+            ui.label(datetime.now(tz=pytz.timezone(timezone)).strftime('%H:%M:%S'))
+
+        clock_ui('Europe/Berlin')
+        ui.button('Refresh', on_click=clock_ui.refresh)
+        ui.button('Refresh for New York', on_click=lambda: clock_ui.refresh('America/New_York'))
+        ui.button('Refresh for Tokyo', on_click=lambda: clock_ui.refresh('Asia/Tokyo'))
+
     @text_demo('Refreshable UI for input validation', '''
         Here is a demo of how to use the refreshable decorator to give feedback about the validity of user input.
     ''')

+ 7 - 2
website/static/search_index.json

@@ -126,7 +126,7 @@
   },
   {
     "title": "Example: SQLite Database",
-    "content": "CRUD operations on a SQLite database",
+    "content": "CRUD operations on a SQLite database with async-support through Tortoise ORM",
     "url": "https://github.com/zauberzeug/nicegui/tree/main/examples/sqlite_database/main.py"
   },
   {
@@ -321,7 +321,7 @@
   },
   {
     "title": "Icon",
-    "content": "This element is based on Quasar's QIcon <https://quasar.dev/vue-components/icon>_ component.  Here <https://material.io/icons/>_ is a reference of possible names.  :param name: name of the icon :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)",
+    "content": "This element is based on Quasar's QIcon <https://quasar.dev/vue-components/icon>_ component.  Here <https://fonts.google.com/icons>_ is a reference of possible names.  :param name: name of the icon :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)",
     "url": "/documentation/icon"
   },
   {
@@ -709,6 +709,11 @@
     "content": "The @ui.refreshable decorator allows you to create functions that have a refresh method. This method will automatically delete all elements created by the function and recreate them. refresh prune",
     "url": "/documentation/refreshable"
   },
+  {
+    "title": "Refreshable: Refreshable UI with parameters",
+    "content": "Here is a demo of how to use the refreshable decorator to create a UI that can be refreshed with different parameters.",
+    "url": "/documentation/refreshable#refreshable_ui_with_parameters"
+  },
   {
     "title": "Refreshable: Refreshable UI for input validation",
     "content": "Here is a demo of how to use the refreshable decorator to give feedback about the validity of user input.",