Browse Source

Merge branch 'main' into aggrid-editable

Falko Schindler 1 year ago
parent
commit
ae3b90310e
69 changed files with 1040 additions and 165 deletions
  1. 13 3
      .github/workflows/test.yml
  2. 1 0
      .gitignore
  3. 3 3
      CITATION.cff
  4. 1 1
      examples/lightbox/main.py
  5. 27 0
      examples/ros2/Dockerfile
  6. 23 0
      examples/ros2/README.md
  7. 9 0
      examples/ros2/docker-compose.yml
  8. 0 0
      examples/ros2/ros2_ws/src/gui/gui/__init__.py
  9. 74 0
      examples/ros2/ros2_ws/src/gui/gui/node.py
  10. 19 0
      examples/ros2/ros2_ws/src/gui/launch/main_launch.py
  11. 16 0
      examples/ros2/ros2_ws/src/gui/package.xml
  12. 4 0
      examples/ros2/ros2_ws/src/gui/setup.cfg
  13. 27 0
      examples/ros2/ros2_ws/src/gui/setup.py
  14. 16 0
      examples/ros2/ros2_ws/src/simulator/package.xml
  15. 4 0
      examples/ros2/ros2_ws/src/simulator/setup.cfg
  16. 25 0
      examples/ros2/ros2_ws/src/simulator/setup.py
  17. 0 0
      examples/ros2/ros2_ws/src/simulator/simulator/__init__.py
  18. 43 0
      examples/ros2/ros2_ws/src/simulator/simulator/node.py
  19. 7 0
      examples/ros2/ros_entrypoint.sh
  20. 6 5
      examples/script_executor/main.py
  21. 1 1
      examples/sqlite_database/.gitignore
  22. 40 70
      examples/sqlite_database/main.py
  23. 7 0
      examples/sqlite_database/models.py
  24. 1 0
      examples/sqlite_database/requirements.txt
  25. 2 1
      main.py
  26. 2 0
      nicegui/background_tasks.py
  27. 2 2
      nicegui/client.py
  28. 1 1
      nicegui/elements/icon.py
  29. 5 1
      nicegui/elements/image.js
  30. 5 0
      nicegui/elements/input.js
  31. 26 17
      nicegui/elements/joystick.py
  32. 4 2
      nicegui/elements/knob.py
  33. 6 0
      nicegui/elements/menu.py
  34. 2 0
      nicegui/elements/query.py
  35. 70 0
      nicegui/elements/scroll_area.py
  36. 11 6
      nicegui/elements/select.js
  37. 1 0
      nicegui/elements/separator.py
  38. 5 1
      nicegui/elements/table.js
  39. 1 0
      nicegui/elements/table.py
  40. 7 7
      nicegui/elements/upload.js
  41. 17 2
      nicegui/events.py
  42. 2 2
      nicegui/favicon.py
  43. 3 1
      nicegui/functions/refreshable.py
  44. 2 3
      nicegui/functions/timer.py
  45. 12 1
      nicegui/helpers.py
  46. 3 9
      nicegui/nicegui.py
  47. 22 0
      nicegui/page_layout.py
  48. 4 0
      nicegui/static/nicegui.css
  49. 5 1
      nicegui/storage.py
  50. 9 1
      nicegui/templates/index.html
  51. 2 0
      nicegui/ui.py
  52. 180 3
      poetry.lock
  53. 2 0
      pyproject.toml
  54. 14 9
      tests/conftest.py
  55. 5 4
      tests/screen.py
  56. 17 0
      tests/test_input.py
  57. 28 0
      tests/test_refreshable.py
  58. 18 0
      tests/test_storage.py
  59. 14 0
      tests/test_table.py
  60. 15 0
      tests/test_timer.py
  61. 2 1
      website/build_search_index.py
  62. 2 0
      website/documentation.py
  63. 5 2
      website/more_documentation/colors_documentation.py
  64. 32 0
      website/more_documentation/log_documentation.py
  65. 12 0
      website/more_documentation/query_documentation.py
  66. 18 0
      website/more_documentation/refreshable_documentation.py
  67. 46 0
      website/more_documentation/scroll_area_documentation.py
  68. 7 0
      website/more_documentation/separator_documentation.py
  69. 25 5
      website/static/search_index.json

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

+ 1 - 1
examples/lightbox/main.py

@@ -14,7 +14,7 @@ class Lightbox:
     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.large_image = ui.image().props('no-spinner fit=scale-down')
         self.image_list: List[str] = []
 
     def add_image(self, thumb_url: str, orig_url: str) -> ui.image:

+ 27 - 0
examples/ros2/Dockerfile

@@ -0,0 +1,27 @@
+FROM ros:humble-ros-base
+
+RUN apt-get update && apt-get install -y \
+    python3-pip \
+    # python3-dev \
+    # build-essential \
+   && rm -rf /var/lib/apt/lists/*
+
+SHELL ["/bin/bash", "-c"]
+
+RUN pip3 install nicegui
+
+ADD ros2_ws /ros2_ws
+
+WORKDIR /ros2_ws
+
+RUN source /opt/ros/humble/setup.bash && \
+    colcon build --symlink-install
+
+COPY ros_entrypoint.sh /
+
+EXPOSE 8080
+
+ENTRYPOINT ["/ros_entrypoint.sh"]
+
+CMD ros2 launch gui main_launch.py
+

+ 23 - 0
examples/ros2/README.md

@@ -0,0 +1,23 @@
+# ROS2 Example
+
+This example is a basic ROS2 implementation with NiceGUI.
+It starts up a user interface with virtual joystick and pose visualization reachable through http://127.0.0.1:8080.
+The joystick publishes twist messages which are consumed by a simple simulator node which itself publishes the new pose of a virtual mobile robot.
+ROS2 and NiceGUI are fully functional in this example.
+
+Over all it is a bit more complex than a super minimal example to allow auto-reloading of the NiceGUI ROS2 node.
+
+<img width="801" alt="Screen Shot 2023-06-26 at 09 36 47" src="https://github.com/zauberzeug/nicegui/assets/131391/ebb280a7-e365-4a11-9d5d-18dd4661b763">
+
+
+## Usage
+
+Change into the `examples/ros2` folder and run:
+
+```bash
+docker compose up --build
+```
+
+Afterwards you can access the user interface at http://127.0.0.1:8080.
+
+If you want to run the NiceGUI node locally or in your own ROS2 project, you can simply copy the code from the `ros2_ws/src/gui` folder.

+ 9 - 0
examples/ros2/docker-compose.yml

@@ -0,0 +1,9 @@
+version: "3"
+services:
+  nicegui:
+    build:
+      context: .
+    ports:
+      - 8080:8080
+    volumes:
+      - ./ros2_ws/src:/ros2_ws/src

+ 0 - 0
examples/ros2/ros2_ws/src/gui/gui/__init__.py


+ 74 - 0
examples/ros2/ros2_ws/src/gui/gui/node.py

@@ -0,0 +1,74 @@
+import math
+import threading
+from pathlib import Path
+
+import rclpy
+from geometry_msgs.msg import Pose, Twist
+from rclpy.executors import ExternalShutdownException
+from rclpy.node import Node
+
+from nicegui import app, globals, run, ui
+
+
+class NiceGuiNode(Node):
+
+    def __init__(self) -> None:
+        super().__init__('nicegui')
+        self.cmd_vel_publisher = self.create_publisher(Twist, 'cmd_vel', 1)
+        self.subscription = self.create_subscription(Pose, 'pose', self.handle_pose, 1)
+
+        with globals.index_client:
+            with ui.row().classes('items-stretch'):
+                with ui.card().classes('w-44 text-center items-center'):
+                    ui.label('Control').classes('text-2xl')
+                    ui.joystick(color='blue', size=50,
+                                on_move=lambda e: self.send_speed(float(e.y), float(e.x)),
+                                on_end=lambda _: self.send_speed(0.0, 0.0))
+                    ui.label('Publish steering commands by dragging your mouse around in the blue field').classes('mt-6')
+                with ui.card().classes('w-44 text-center items-center'):
+                    ui.label('Data').classes('text-2xl')
+                    ui.label('linear velocity').classes('text-xs mb-[-1.8em]')
+                    slider_props = 'readonly selection-color=transparent'
+                    self.linear = ui.slider(min=-1, max=1, step=0.05, value=0).props(slider_props)
+                    ui.label('angular velocity').classes('text-xs mb-[-1.8em]')
+                    self.angular = ui.slider(min=-1, max=1, step=0.05, value=0).props(slider_props)
+                    ui.label('position').classes('text-xs mb-[-1.4em]')
+                    self.position = ui.label('---')
+                with ui.card().classes('w-96 h-96 items-center'):
+                    ui.label('Visualization').classes('text-2xl')
+                    with ui.scene(350, 300) as scene:
+                        with scene.group() as self.robot_3d:
+                            prism = [[-0.5, -0.5], [0.5, -0.5], [0.75, 0], [0.5, 0.5], [-0.5, 0.5]]
+                            self.robot_object = scene.extrusion(prism, 0.4).material('#4488ff', 0.5)
+
+    def send_speed(self, x: float, y: float) -> None:
+        msg = Twist()
+        msg.linear.x = x
+        msg.angular.z = -y
+        self.linear.value = x
+        self.angular.value = y
+        self.cmd_vel_publisher.publish(msg)
+
+    def handle_pose(self, msg: Pose) -> None:
+        self.position.text = f'x: {msg.position.x:.2f}, y: {msg.position.y:.2f}'
+        self.robot_3d.move(msg.position.x, msg.position.y)
+        self.robot_3d.rotate(0, 0, 2 * math.atan2(msg.orientation.z, msg.orientation.w))
+
+
+def main() -> None:
+    # NOTE: This function is defined as the ROS entry point in setup.py, but it's empty to enable NiceGUI auto-reloading
+    pass
+
+
+def ros_main() -> None:
+    rclpy.init()
+    node = NiceGuiNode()
+    try:
+        rclpy.spin(node)
+    except ExternalShutdownException:
+        pass
+    
+
+app.on_startup(lambda: threading.Thread(target=ros_main).start())
+run.APP_IMPORT_STRING = f'{__name__}:app'  # ROS2 uses a non-standard module name, so we need to specify it here
+ui.run(uvicorn_reload_dirs=str(Path(__file__).parent.resolve()), favicon='🤖')

+ 19 - 0
examples/ros2/ros2_ws/src/gui/launch/main_launch.py

@@ -0,0 +1,19 @@
+from launch import LaunchDescription
+from launch_ros.actions import Node
+
+
+def generate_launch_description():
+    return LaunchDescription([
+        Node(
+            package='gui',
+            executable='nicegui_node',
+            name='example_gui',
+            output='screen',
+        ),
+        Node(
+            package='simulator',
+            executable='simulator_node',
+            name='example_simulator',
+            output='screen',
+        ),
+    ])

+ 16 - 0
examples/ros2/ros2_ws/src/gui/package.xml

@@ -0,0 +1,16 @@
+<?xml version="1.0"?>
+<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
+<package format="3">
+  <name>gui</name>
+  <version>1.0.0</version>
+  <description>This is an example of NiceGUI in a ROS2 node that uses a joystick to send geometry_msgs/Twist messages.</description>
+  <maintainer email="nicegui@zauberzeug.com">Zauberzeug GmbH</maintainer>
+  <license>MIT License</license>
+  
+  <depend>rclpy</depend>
+  <depend>geometry_msgs</depend>
+
+  <export>
+    <build_type>ament_python</build_type>
+  </export>
+</package>

+ 4 - 0
examples/ros2/ros2_ws/src/gui/setup.cfg

@@ -0,0 +1,4 @@
+[develop]
+script_dir=$base/lib/gui
+[install]
+install_scripts=$base/lib/gui

+ 27 - 0
examples/ros2/ros2_ws/src/gui/setup.py

@@ -0,0 +1,27 @@
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+from setuptools import setup
+
+package_xml = ET.parse('package.xml').getroot()
+package_name = package_xml.find('name').text
+data = Path('share') / package_name
+setup(
+    name=package_name,
+    version=package_xml.find('version').text,
+    packages=[package_name],
+    maintainer=package_xml.find('license').text,
+    maintainer_email=package_xml.find('maintainer').attrib['email'],
+    license=package_xml.find('license').text,
+    data_files=[
+        (str(data), ['package.xml']),
+        (str(data / 'launch'), ['launch/main_launch.py']),
+    ],
+    install_requires=['setuptools'],
+    zip_safe=True,
+    entry_points={
+        'console_scripts': [
+            'nicegui_node = gui.node:main',
+        ],
+    },
+)

+ 16 - 0
examples/ros2/ros2_ws/src/simulator/package.xml

@@ -0,0 +1,16 @@
+<?xml version="1.0"?>
+<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
+<package format="3">
+  <name>simulator</name>
+  <version>1.0.0</version>
+  <description>A very simple simulator which receives geometry_msgs/Twist messages and publishes geometry_msgs/Pose messages on the "pose" topic.</description>
+  <maintainer email="nicegui@zauberzeug.com">Zauberzeug GmbH</maintainer>
+  <license>MIT License</license>
+  
+  <depend>rclpy</depend>
+  <depend>geometry_msgs</depend>
+
+  <export>
+    <build_type>ament_python</build_type>
+  </export>
+</package>

+ 4 - 0
examples/ros2/ros2_ws/src/simulator/setup.cfg

@@ -0,0 +1,4 @@
+[develop]
+script_dir=$base/lib/simulator
+[install]
+install_scripts=$base/lib/simulator

+ 25 - 0
examples/ros2/ros2_ws/src/simulator/setup.py

@@ -0,0 +1,25 @@
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+from setuptools import setup
+
+package_xml = ET.parse('package.xml').getroot()
+package_name = package_xml.find('name').text
+setup(
+    name=package_name,
+    version=package_xml.find('version').text,
+    packages=[package_name],
+    maintainer=package_xml.find('license').text,
+    maintainer_email=package_xml.find('maintainer').attrib['email'],
+    license=package_xml.find('license').text,
+    data_files=[
+        (str(Path('share') / package_name), ['package.xml']),
+    ],
+    install_requires=['setuptools'],
+    zip_safe=True,
+    entry_points={
+        'console_scripts': [
+            'simulator_node = simulator.node:main',
+        ],
+    },
+)

+ 0 - 0
examples/ros2/ros2_ws/src/simulator/simulator/__init__.py


+ 43 - 0
examples/ros2/ros2_ws/src/simulator/simulator/node.py

@@ -0,0 +1,43 @@
+import math
+
+import rclpy
+from geometry_msgs.msg import Pose, Twist
+from rclpy.node import Node
+
+
+class Simulator(Node):
+    INTERVAL = 0.1
+
+    def __init__(self) -> None:
+        super().__init__('simulator')
+        self.pose_publisher_ = self.create_publisher(Pose, 'pose', 1)
+        self.subscription = self.create_subscription(Twist, 'cmd_vel', self.handle_velocity_command, 1)
+        self.pose = Pose()
+        self.linear_velocity = 0.0
+        self.angular_velocity = 0.0
+        self.timer = self.create_timer(self.INTERVAL, self.update_pose)
+
+    def handle_velocity_command(self, msg: Twist) -> None:
+        self.linear_velocity = msg.linear.x
+        self.angular_velocity = msg.angular.z
+
+    def update_pose(self) -> None:
+        yaw = 2 * math.atan2(self.pose.orientation.z, self.pose.orientation.w)
+        self.pose.position.x += self.linear_velocity * math.cos(yaw) * self.INTERVAL
+        self.pose.position.y += self.linear_velocity * math.sin(yaw) * self.INTERVAL
+        yaw += self.angular_velocity * self.INTERVAL
+        self.pose.orientation.z = math.sin(yaw / 2)
+        self.pose.orientation.w = math.cos(yaw / 2)
+        self.pose_publisher_.publish(self.pose)
+
+
+def main(args=None) -> None:
+    rclpy.init(args=args)
+    simulator = Simulator()
+    rclpy.spin(simulator)
+    simulator.destroy_node()
+    rclpy.shutdown()
+
+
+if __name__ == '__main__':
+    main()

+ 7 - 0
examples/ros2/ros_entrypoint.sh

@@ -0,0 +1,7 @@
+#!/bin/bash
+set -e
+
+source /opt/ros/humble/setup.bash
+source install/setup.bash
+
+exec "$@"

+ 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

+ 2 - 1
main.py

@@ -281,9 +281,10 @@ 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')
 
     with ui.row().classes('bg-primary w-full min-h-screen mt-16'):
         link_target('why')

+ 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
 

+ 2 - 2
nicegui/client.py

@@ -35,8 +35,8 @@ class Client:
         self.environ: Optional[Dict[str, Any]] = None
         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-layout', _client=self).props('view="hhh lpr fff"').classes('nicegui-layout') as self.layout:
+            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

+ 5 - 1
nicegui/elements/image.js

@@ -1,6 +1,10 @@
 export default {
   template: `
-    <q-img v-bind="$attrs" :src="computed_src">
+    <q-img
+      ref="qRef"
+      v-bind="$attrs"
+      :src="computed_src"
+    >
       <template v-for="(_, slot) in $slots" v-slot:[slot]="slotProps">
         <slot :name="slot" v-bind="slotProps || {}" />
       </template>

+ 5 - 0
nicegui/elements/input.js

@@ -1,6 +1,7 @@
 export default {
   template: `
     <q-input
+      ref="qRef"
       v-bind="$attrs"
       v-model="inputValue"
       :shadow-text="shadowText"
@@ -23,13 +24,17 @@ export default {
   data() {
     return {
       inputValue: this.value,
+      emitting: true,
     };
   },
   watch: {
     value(newValue) {
+      this.emitting = false;
       this.inputValue = newValue;
+      this.$nextTick(() => (this.emitting = true));
     },
     inputValue(newValue) {
+      if (!this.emitting) return;
       this.$emit("update:value", newValue);
     },
   },

+ 26 - 17
nicegui/elements/joystick.py

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

+ 4 - 2
nicegui/elements/knob.py

@@ -1,4 +1,4 @@
-from typing import Optional
+from typing import Any, Callable, Optional
 
 from .label import Label
 from .mixins.color_elements import TextColorElement
@@ -19,6 +19,7 @@ class Knob(ValueElement, DisableableElement, TextColorElement):
                  track_color: Optional[str] = None,
                  size: Optional[str] = None,
                  show_value: bool = False,
+                 on_change: Optional[Callable[..., Any]] = None,
                  ) -> None:
         """Knob
 
@@ -34,8 +35,9 @@ class Knob(ValueElement, DisableableElement, TextColorElement):
         :param track_color: color name for the track of the component, examples: primary, teal-10
         :param size: size in CSS units, including unit name or standard size name (xs|sm|md|lg|xl), examples: 16px, 2rem
         :param show_value: whether to show the value as text
+        :param on_change: callback to execute when the value changes
         """
-        super().__init__(tag='q-knob', value=value, on_value_change=None, throttle=0.05, text_color=color)
+        super().__init__(tag='q-knob', value=value, on_value_change=on_change, throttle=0.05, text_color=color)
 
         self._props['min'] = min
         self._props['max'] = max

+ 6 - 0
nicegui/elements/menu.py

@@ -20,11 +20,17 @@ class Menu(ValueElement):
         self._props['no-parent-event'] = True
 
     def open(self) -> None:
+        """Open the menu."""
         self.value = True
 
     def close(self) -> None:
+        """Close the menu."""
         self.value = False
 
+    def toggle(self) -> None:
+        """Toggle the menu."""
+        self.value = not self.value
+
 
 class MenuItem(TextElement):
 

+ 2 - 0
nicegui/elements/query.py

@@ -62,6 +62,8 @@ def query(selector: str) -> Query:
     To manipulate elements like the document body, you can use the `ui.query` function.
     With the query result you can add classes, styles, and attributes like with every other UI element.
     This can be useful for example to change the background color of the page (e.g. `ui.query('body').classes('bg-green')`).
+
+    :param selector: the CSS selector (e.g. "body", "#my-id", ".my-class", "div > p")
     """
     for element in get_client().elements.values():
         if isinstance(element, Query) and element._props['selector'] == selector:

+ 70 - 0
nicegui/elements/scroll_area.py

@@ -0,0 +1,70 @@
+from typing import Any, Callable, Dict, Optional
+
+from typing_extensions import Literal
+
+from ..element import Element
+from ..events import ScrollEventArguments, handle_event
+
+
+class ScrollArea(Element):
+
+    def __init__(self, *, on_scroll: Optional[Callable[..., Any]] = None) -> None:
+        """Scroll Area
+
+        A way of customizing the scrollbars by encapsulating your content.
+        This element exposes the Quasar `ScrollArea <https://quasar.dev/vue-components/scroll-area/>`_ component.
+
+        :param on_scroll: function to be called when the scroll position changes
+        """
+        super().__init__('q-scroll-area')
+        self._classes = ['nicegui-scroll-area']
+
+        if on_scroll:
+            self.on('scroll', lambda msg: self._handle_scroll(on_scroll, msg), args=[
+                'verticalPosition',
+                'verticalPercentage',
+                'verticalSize',
+                'verticalContainerSize',
+                'horizontalPosition',
+                'horizontalPercentage',
+                'horizontalSize',
+                'horizontalContainerSize',
+            ])
+
+    def _handle_scroll(self, on_scroll: Callable[..., Any], msg: Dict) -> None:
+        handle_event(on_scroll, ScrollEventArguments(
+            sender=self,
+            client=self.client,
+            vertical_position=msg['args']['verticalPosition'],
+            vertical_percentage=msg['args']['verticalPercentage'],
+            vertical_size=msg['args']['verticalSize'],
+            vertical_container_size=msg['args']['verticalContainerSize'],
+            horizontal_position=msg['args']['horizontalPosition'],
+            horizontal_percentage=msg['args']['horizontalPercentage'],
+            horizontal_size=msg['args']['horizontalSize'],
+            horizontal_container_size=msg['args']['horizontalContainerSize'],
+        ))
+
+    def scroll_to(self, *,
+                  pixels: Optional[float] = None,
+                  percent: Optional[float] = None,
+                  axis: Literal['vertical', 'horizontal'] = 'vertical',
+                  duration: float = 0.0,
+                  ) -> None:
+        """Set the scroll area position in percentage (float) or pixel number (int).
+
+        You can add a delay to the actual scroll action with the `duration_ms` parameter.
+
+        :param pixels: scroll position offset from top in pixels
+        :param percent: scroll position offset from top in percentage of the total scrolling size
+        :param axis: scroll axis
+        :param duration: animation duration (in seconds, default: 0.0 means no animation)
+        """
+        if pixels is not None and percent is not None:
+            raise ValueError('You can only specify one of pixels or percent')
+        if pixels is not None:
+            self.run_method('setScrollPosition', axis, pixels, 1000 * duration)
+        elif percent is not None:
+            self.run_method('setScrollPercentage', axis, percent, 1000 * duration)
+        else:
+            raise ValueError('You must specify one of pixels or percent')

+ 11 - 6
nicegui/elements/select.js

@@ -1,12 +1,17 @@
 export default {
   props: ["options"],
   template: `
-      <q-select v-bind="$attrs" :options="filteredOptions" @filter="filterFn">
-          <template v-for="(_, slot) in $slots" v-slot:[slot]="slotProps">
-              <slot :name="slot" v-bind="slotProps || {}" />
-          </template>
-      </q-select>
-    `,
+    <q-select
+      ref="qRef"
+      v-bind="$attrs"
+      :options="filteredOptions"
+      @filter="filterFn"
+    >
+      <template v-for="(_, slot) in $slots" v-slot:[slot]="slotProps">
+        <slot :name="slot" v-bind="slotProps || {}" />
+      </template>
+    </q-select>
+  `,
   data() {
     return {
       initialOptions: this.options,

+ 1 - 0
nicegui/elements/separator.py

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

+ 5 - 1
nicegui/elements/table.js

@@ -1,6 +1,10 @@
 export default {
   template: `
-    <q-table v-bind="$attrs" :columns="convertedColumns">
+    <q-table
+      ref="qRef"
+      v-bind="$attrs"
+      :columns="convertedColumns"
+    >
       <template v-for="(_, slot) in $slots" v-slot:[slot]="slotProps">
         <slot :name="slot" v-bind="slotProps || {}" />
       </template>

+ 1 - 0
nicegui/elements/table.py

@@ -71,6 +71,7 @@ class Table(FilterElement):
         """Remove rows from the table."""
         keys = [row[self.row_key] for row in rows]
         self.rows[:] = [row for row in self.rows if row[self.row_key] not in keys]
+        self.selected[:] = [row for row in self.selected if row[self.row_key] not in keys]
         self.update()
 
     class row(Element):

+ 7 - 7
nicegui/elements/upload.js

@@ -1,9 +1,12 @@
 export default {
   template: `
-    <q-uploader ref="uploader" :url="computed_url">
-        <template v-for="(_, slot) in $slots" v-slot:[slot]="slotProps">
-            <slot :name="slot" v-bind="slotProps || {}" />
-        </template>
+    <q-uploader
+      ref="qRef"
+      :url="computed_url"
+    >
+      <template v-for="(_, slot) in $slots" v-slot:[slot]="slotProps">
+        <slot :name="slot" v-bind="slotProps || {}" />
+      </template>
     </q-uploader>
   `,
   mounted() {
@@ -16,9 +19,6 @@ export default {
     compute_url() {
       this.computed_url = (this.url.startsWith("/") ? window.path_prefix : "") + this.url;
     },
-    reset() {
-      this.$refs.uploader.reset();
-    },
   },
   props: {
     url: String,

+ 17 - 2
nicegui/events.py

@@ -268,19 +268,34 @@ class KeyEventArguments(EventArguments):
     modifiers: KeyboardModifiers
 
 
+@dataclass(**KWONLY_SLOTS)
+class ScrollEventArguments(EventArguments):
+    vertical_position: float
+    vertical_percentage: float
+    vertical_size: float
+    vertical_container_size: float
+    horizontal_position: float
+    horizontal_percentage: float
+    horizontal_size: float
+    horizontal_container_size: float
+
+
 def handle_event(handler: Optional[Callable[..., Any]],
                  arguments: Union[EventArguments, Dict], *,
                  sender: Optional['Element'] = None) -> None:
     if handler is None:
         return
     try:
-        no_arguments = not any(p.default is Parameter.empty for p in signature(handler).parameters.values())
+        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 sender.parent_slot:
-            result = handler() if no_arguments else handler(arguments)
+            result = handler(arguments) if expects_arguments else handler()
         if isinstance(result, Awaitable):
             async def wait_for_result():
                 with sender.parent_slot:

+ 2 - 2
nicegui/favicon.py

@@ -15,7 +15,7 @@ if TYPE_CHECKING:
 
 def create_favicon_route(path: str, favicon: Optional[Union[str, Path]]) -> None:
     if is_file(favicon):
-        globals.app.add_route(f'{path}/favicon.ico', lambda _: FileResponse(favicon))
+        globals.app.add_route('/favicon.ico' if path == '/' else f'{path}/favicon.ico', lambda _: FileResponse(favicon))
 
 
 def get_favicon_url(page: 'page', prefix: str) -> str:
@@ -31,7 +31,7 @@ def get_favicon_url(page: 'page', prefix: str) -> str:
         return svg_to_data_url(favicon)
     elif is_char(favicon):
         return svg_to_data_url(char_to_svg(favicon))
-    elif page.path == '/':
+    elif page.path == '/' or page.favicon is None:
         return f'{prefix}/favicon.ico'
     else:
         return f'{prefix}{page.path}/favicon.ico'

+ 3 - 1
nicegui/functions/refreshable.py

@@ -58,12 +58,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)

+ 12 - 1
nicegui/helpers.py

@@ -9,8 +9,9 @@ import time
 import webbrowser
 from contextlib import nullcontext
 from pathlib import Path
-from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generator, Optional, Tuple, Union
+from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generator, List, Optional, Tuple, Union
 
+import netifaces
 from fastapi import Request
 from fastapi.responses import StreamingResponse
 from starlette.middleware import Middleware
@@ -157,3 +158,13 @@ def get_streaming_response(file: Path, request: Request) -> StreamingResponse:
         headers=headers,
         status_code=206,
     )
+
+
+def get_all_ips() -> List[str]:
+    ips = []
+    for interface in netifaces.interfaces():
+        try:
+            ips.append(netifaces.ifaddresses(interface)[netifaces.AF_INET][0]['addr'])
+        except KeyError:
+            pass
+    return ips

+ 3 - 9
nicegui/nicegui.py

@@ -1,6 +1,5 @@
 import asyncio
 import os
-import socket
 import time
 import urllib.parse
 from pathlib import Path
@@ -21,7 +20,7 @@ from .client import Client
 from .dependencies import js_components, js_dependencies
 from .element import Element
 from .error import error_content
-from .helpers import is_file, safe_invoke
+from .helpers import get_all_ips, is_file, safe_invoke
 from .page import page
 
 globals.app = app = App(default_response_class=NiceGUIJSONResponse)
@@ -90,17 +89,12 @@ def handle_startup(with_welcome_message: bool = True) -> None:
 def print_welcome_message():
     host = os.environ['NICEGUI_HOST']
     port = os.environ['NICEGUI_PORT']
-    ips = set()
-    if host == '0.0.0.0':
-        try:
-            ips.update(set(info[4][0] for info in socket.getaddrinfo(socket.gethostname(), None) if len(info[4]) == 2))
-        except Exception:
-            pass  # NOTE: if we can't get the host's IP, we'll just use localhost
+    ips = set(get_all_ips() if host == '0.0.0.0' else [])
     ips.discard('127.0.0.1')
     addresses = [(f'http://{ip}:{port}' if port != '80' else f'http://{ip}') for ip in ['localhost'] + sorted(ips)]
     if len(addresses) >= 2:
         addresses[-1] = 'and ' + addresses[-1]
-    print(f'NiceGUI ready to go on {", ".join(addresses)}')
+    print(f'NiceGUI ready to go on {", ".join(addresses)}', flush=True)
 
 
 @app.on_event('shutdown')

+ 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

+ 4 - 0
nicegui/static/nicegui.css

@@ -64,6 +64,10 @@
   width: 100%;
   height: 16rem;
 }
+.nicegui-scroll-area {
+  width: 100%;
+  height: 16rem;
+}
 .nicegui-log {
   padding: 0.25rem;
   border-width: 1px;

+ 5 - 1
nicegui/storage.py

@@ -45,6 +45,11 @@ class PersistentDict(observables.ObservableDict):
         super().__init__(data, self.backup)
 
     def backup(self) -> None:
+        if not self.filepath.exists():
+            if not self:
+                return
+            self.filepath.parent.mkdir(exist_ok=True)
+
         async def backup() -> None:
             async with aiofiles.open(self.filepath, 'w') as f:
                 await f.write(json.dumps(self))
@@ -70,7 +75,6 @@ class Storage:
 
     def __init__(self) -> None:
         self.storage_dir = Path('.nicegui')
-        self.storage_dir.mkdir(exist_ok=True)
         self._general = PersistentDict(self.storage_dir / 'storage_general.json')
         self._users: Dict[str, PersistentDict] = {}
 

+ 9 - 1
nicegui/templates/index.html

@@ -178,7 +178,15 @@
             document.getElementById('popup').style.opacity = 1;
           });
           window.socket.on("update", (msg) => Object.entries(msg).forEach(([id, el]) => this.elements[el.id] = el));
-          window.socket.on("run_method", (msg) => getElement(msg.id)?.[msg.name](...msg.args));
+          window.socket.on("run_method", (msg) => {
+            const element = getElement(msg.id);
+            if (element === null || element === undefined) return;
+            if (msg.name in element) {
+              element[msg.name](...msg.args);
+            } else {
+              element.$refs.qRef[msg.name](...msg.args);
+            }
+          });
           window.socket.on("run_javascript", (msg) => runJavascript(msg['code'], msg['request_id']));
           window.socket.on("open", (msg) => (location.href = msg.startsWith('/') ? "{{ prefix | safe }}" + msg : msg));
           window.socket.on("download", (msg) => download(msg.url, msg.filename));

+ 2 - 0
nicegui/ui.py

@@ -47,6 +47,7 @@ __all__ = [
     'radio',
     'row',
     'scene',
+    'scroll_area',
     'select',
     'separator',
     'slider',
@@ -134,6 +135,7 @@ from .elements.query import query
 from .elements.radio import Radio as radio
 from .elements.row import Row as row
 from .elements.scene import Scene as scene
+from .elements.scroll_area import ScrollArea as scroll_area
 from .elements.select import Select as select
 from .elements.separator import Separator as separator
 from .elements.slider import Slider as slider

+ 180 - 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 = [
@@ -1012,10 +1050,51 @@ pillow = ">=6.2.0"
 pyparsing = ">=2.3.1"
 python-dateutil = ">=2.7"
 
+[[package]]
+name = "netifaces"
+version = "0.11.0"
+description = "Portable network interface information."
+category = "main"
+optional = false
+python-versions = "*"
+files = [
+    {file = "netifaces-0.11.0-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eb4813b77d5df99903af4757ce980a98c4d702bbcb81f32a0b305a1537bdf0b1"},
+    {file = "netifaces-0.11.0-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:5f9ca13babe4d845e400921973f6165a4c2f9f3379c7abfc7478160e25d196a4"},
+    {file = "netifaces-0.11.0-cp27-cp27m-win32.whl", hash = "sha256:7dbb71ea26d304e78ccccf6faccef71bb27ea35e259fb883cfd7fd7b4f17ecb1"},
+    {file = "netifaces-0.11.0-cp27-cp27m-win_amd64.whl", hash = "sha256:0f6133ac02521270d9f7c490f0c8c60638ff4aec8338efeff10a1b51506abe85"},
+    {file = "netifaces-0.11.0-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:08e3f102a59f9eaef70948340aeb6c89bd09734e0dca0f3b82720305729f63ea"},
+    {file = "netifaces-0.11.0-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c03fb2d4ef4e393f2e6ffc6376410a22a3544f164b336b3a355226653e5efd89"},
+    {file = "netifaces-0.11.0-cp34-cp34m-win32.whl", hash = "sha256:73ff21559675150d31deea8f1f8d7e9a9a7e4688732a94d71327082f517fc6b4"},
+    {file = "netifaces-0.11.0-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:815eafdf8b8f2e61370afc6add6194bd5a7252ae44c667e96c4c1ecf418811e4"},
+    {file = "netifaces-0.11.0-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:50721858c935a76b83dd0dd1ab472cad0a3ef540a1408057624604002fcfb45b"},
+    {file = "netifaces-0.11.0-cp35-cp35m-win32.whl", hash = "sha256:c9a3a47cd3aaeb71e93e681d9816c56406ed755b9442e981b07e3618fb71d2ac"},
+    {file = "netifaces-0.11.0-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:aab1dbfdc55086c789f0eb37affccf47b895b98d490738b81f3b2360100426be"},
+    {file = "netifaces-0.11.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c37a1ca83825bc6f54dddf5277e9c65dec2f1b4d0ba44b8fd42bc30c91aa6ea1"},
+    {file = "netifaces-0.11.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:28f4bf3a1361ab3ed93c5ef360c8b7d4a4ae060176a3529e72e5e4ffc4afd8b0"},
+    {file = "netifaces-0.11.0-cp36-cp36m-win32.whl", hash = "sha256:2650beee182fed66617e18474b943e72e52f10a24dc8cac1db36c41ee9c041b7"},
+    {file = "netifaces-0.11.0-cp36-cp36m-win_amd64.whl", hash = "sha256:cb925e1ca024d6f9b4f9b01d83215fd00fe69d095d0255ff3f64bffda74025c8"},
+    {file = "netifaces-0.11.0-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:84e4d2e6973eccc52778735befc01638498781ce0e39aa2044ccfd2385c03246"},
+    {file = "netifaces-0.11.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:18917fbbdcb2d4f897153c5ddbb56b31fa6dd7c3fa9608b7e3c3a663df8206b5"},
+    {file = "netifaces-0.11.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:48324183af7f1bc44f5f197f3dad54a809ad1ef0c78baee2c88f16a5de02c4c9"},
+    {file = "netifaces-0.11.0-cp37-cp37m-win32.whl", hash = "sha256:8f7da24eab0d4184715d96208b38d373fd15c37b0dafb74756c638bd619ba150"},
+    {file = "netifaces-0.11.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2479bb4bb50968089a7c045f24d120f37026d7e802ec134c4490eae994c729b5"},
+    {file = "netifaces-0.11.0-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:3ecb3f37c31d5d51d2a4d935cfa81c9bc956687c6f5237021b36d6fdc2815b2c"},
+    {file = "netifaces-0.11.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:96c0fe9696398253f93482c84814f0e7290eee0bfec11563bd07d80d701280c3"},
+    {file = "netifaces-0.11.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c92ff9ac7c2282009fe0dcb67ee3cd17978cffbe0c8f4b471c00fe4325c9b4d4"},
+    {file = "netifaces-0.11.0-cp38-cp38-win32.whl", hash = "sha256:d07b01c51b0b6ceb0f09fc48ec58debd99d2c8430b09e56651addeaf5de48048"},
+    {file = "netifaces-0.11.0-cp38-cp38-win_amd64.whl", hash = "sha256:469fc61034f3daf095e02f9f1bbac07927b826c76b745207287bc594884cfd05"},
+    {file = "netifaces-0.11.0-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:5be83986100ed1fdfa78f11ccff9e4757297735ac17391b95e17e74335c2047d"},
+    {file = "netifaces-0.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:54ff6624eb95b8a07e79aa8817288659af174e954cca24cdb0daeeddfc03c4ff"},
+    {file = "netifaces-0.11.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:841aa21110a20dc1621e3dd9f922c64ca64dd1eb213c47267a2c324d823f6c8f"},
+    {file = "netifaces-0.11.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e76c7f351e0444721e85f975ae92718e21c1f361bda946d60a214061de1f00a1"},
+    {file = "netifaces-0.11.0.tar.gz", hash = "sha256:043a79146eb2907edf439899f262b3dfe41717d34124298ed281139a8b93ca32"},
+]
+
 [[package]]
 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 +1132,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 +1170,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 +1226,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 +1241,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 +1253,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 +1295,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 +1363,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 +1443,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 +1459,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 +1478,7 @@ testing = ["pytest", "pytest-benchmark"]
 name = "proxy-tools"
 version = "0.1.0"
 description = "Proxy Implementation"
+category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1400,6 +1489,7 @@ files = [
 name = "pscript"
 version = "0.7.7"
 description = "Python to JavaScript compiler."
+category = "main"
 optional = false
 python-versions = "*"
 files = [
@@ -1411,6 +1501,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 +1513,7 @@ files = [
 name = "pycodestyle"
 version = "2.10.0"
 description = "Python style guide checker"
+category = "dev"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -1433,6 +1525,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 +1537,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 +1590,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 +1605,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 +1622,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 +1642,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 +1660,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 +1675,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 +1688,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 +1714,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 +1733,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 +1749,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 +1766,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 +1781,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 +1806,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 +1826,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 +1841,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 +1856,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 +1872,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 +1887,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 +1907,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 +1931,7 @@ pycparser = "*"
 name = "pytz"
 version = "2023.3"
 description = "World timezone definitions, modern and historical"
+category = "dev"
 optional = false
 python-versions = "*"
 files = [
@@ -1829,6 +1943,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 +1971,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 +2021,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 +2039,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 +2061,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 +2073,7 @@ files = [
 name = "selenium"
 version = "4.9.1"
 description = ""
+category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1971,6 +2091,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 +2103,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 +2115,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 +2127,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 +2146,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 +2161,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 +2169,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 +2216,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 +2233,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 +2245,7 @@ files = [
 name = "tzdata"
 version = "2023.3"
 description = "Provider of IANA time zone data"
+category = "dev"
 optional = false
 python-versions = ">=2"
 files = [
@@ -2106,6 +2257,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 +2278,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 +2294,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 +2305,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 +2350,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 +2365,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 +2392,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 +2494,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 +2509,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 +2524,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 = "97262dca004a99b4ee92c1abffeaf2f3b9ecc7fe902aaad83412e9f3d473c363"

+ 2 - 0
pyproject.toml

@@ -30,6 +30,7 @@ pywebview = "^4.0.2"
 importlib_metadata = { version = "^6.0.0", markers = "python_version ~= '3.7'" } # Python 3.7 has no importlib.metadata
 itsdangerous = "^2.1.2"
 aiofiles = "^23.1.0"
+netifaces = "^0.11.0"
 
 [tool.poetry.group.dev.dependencies]
 icecream = "^2.1.0"
@@ -46,6 +47,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]

+ 17 - 0
tests/test_input.py

@@ -141,3 +141,20 @@ def test_update_input(screen: Screen):
     input.value = 'Pete'
     screen.wait(0.5)
     assert element.get_attribute('value') == 'Pete'
+
+
+def test_switching_focus(screen: Screen):
+    input1 = ui.input()
+    input2 = ui.input()
+    ui.button('focus 1', on_click=lambda: input1.run_method('focus'))
+    ui.button('focus 2', on_click=lambda: input2.run_method('focus'))
+
+    screen.open('/')
+    elements = screen.selenium.find_elements(By.XPATH, '//input')
+    assert len(elements) == 2
+    screen.click('focus 1')
+    screen.wait(0.3)
+    assert elements[0] == screen.selenium.switch_to.active_element
+    screen.click('focus 2')
+    screen.wait(0.3)
+    assert elements[1] == screen.selenium.switch_to.active_element

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

+ 18 - 0
tests/test_storage.py

@@ -1,4 +1,5 @@
 import asyncio
+import warnings
 from pathlib import Path
 
 import httpx
@@ -89,6 +90,7 @@ async def test_access_user_storage_from_fastapi(screen: Screen):
         response = await http_client.get(f'http://localhost:{PORT}/api')
         assert response.status_code == 200
         assert response.text == '"OK"'
+        await asyncio.sleep(0.5)  # wait for storage to be written
         assert next(Path('.nicegui').glob('storage_user_*.json')).read_text() == '{"msg": "yes"}'
 
 
@@ -149,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()

+ 14 - 0
tests/test_table.py

@@ -110,3 +110,17 @@ def test_dynamic_column_attributes(screen: Screen):
 
     screen.open('/')
     screen.should_contain('18 years')
+
+
+def test_remove_selection(screen: Screen):
+    t = ui.table(columns=columns(), rows=rows(), selection='single')
+    ui.button('Remove first row', on_click=lambda: t.remove_rows(t.rows[0]))
+
+    screen.open('/')
+    screen.find('Alice').find_element(By.XPATH, 'preceding-sibling::td').click()
+    screen.should_contain('1 record selected.')
+
+    screen.click('Remove first row')
+    screen.wait(0.5)
+    screen.should_not_contain('Alice')
+    screen.should_not_contain('1 record selected.')

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

+ 2 - 1
website/build_search_index.py

@@ -132,10 +132,11 @@ class MainVisitor(ast.NodeVisitor):
         if function_name == 'example_link':
             title = ast_string_node_to_string(node.args[0])
             name = name = title.lower().replace(' ', '_')
+            file = 'main.py' if not 'ros' in name else ''  # TODO: generalize hack to use folder if main.py is not available
             documents.append({
                 'title': 'Example: ' + title,
                 'content': ast_string_node_to_string(node.args[1]),
-                'url': f'https://github.com/zauberzeug/nicegui/tree/main/examples/{name}/main.py',
+                'url': f'https://github.com/zauberzeug/nicegui/tree/main/examples/{name}/{file}',
             })
 
 

+ 2 - 0
website/documentation.py

@@ -165,6 +165,8 @@ def create_full() -> None:
         ui.button('Clear', on_click=container.clear)
 
     load_demo(ui.expansion)
+    load_demo(ui.scroll_area)
+    load_demo(ui.separator)
     load_demo(ui.splitter)
     load_demo('tabs')
     load_demo(ui.stepper)

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

+ 32 - 0
website/more_documentation/log_documentation.py

@@ -1,4 +1,5 @@
 from nicegui import ui
+from website.documentation_tools import text_demo
 
 
 def main_demo() -> None:
@@ -6,3 +7,34 @@ def main_demo() -> None:
 
     log = ui.log(max_lines=10).classes('w-full h-20')
     ui.button('Log time', on_click=lambda: log.push(datetime.now().strftime('%X.%f')[:-5]))
+
+
+def more() -> None:
+    @text_demo('Attach to a logger', '''
+        You can attach a `ui.log` element to a Python logger object so that log messages are pushed to the log element.
+    ''')
+    def logger_handler():
+        import logging
+        from datetime import datetime
+
+        logger = logging.getLogger()
+
+        class LogElementHandler(logging.Handler):
+            """A logging handler that emits messages to a log element."""
+
+            def __init__(self, element: ui.log, level: int = logging.NOTSET) -> None:
+                self.element = element
+                super().__init__(level)
+
+            def emit(self, record: logging.LogRecord) -> None:
+                try:
+                    msg = self.format(record)
+                    self.element.push(msg)
+                except (KeyboardInterrupt, SystemExit):
+                    raise
+                except:
+                    self.handleError(record)
+
+        log = ui.log(max_lines=10).classes('w-full')
+        logger.addHandler(LogElementHandler(log))
+        ui.button('Log time', on_click=lambda: logger.warning(datetime.now().strftime('%X.%f')[:-5]))

+ 12 - 0
website/more_documentation/query_documentation.py

@@ -23,3 +23,15 @@ def more() -> None:
         # ui.query('body').classes('bg-gradient-to-t from-blue-400 to-blue-100')
         # END OF DEMO
         globals.get_slot_stack()[-1].parent.classes('bg-gradient-to-t from-blue-400 to-blue-100')
+
+    @text_demo('Modify default page padding', '''
+        By default, NiceGUI provides a built-in padding around the content of the page.
+        You can modify it using the class selector `.nicegui-content`.
+    ''')
+    def remove_padding():
+        # ui.query('.nicegui-content').classes('p-0')
+        globals.get_slot_stack()[-1].parent.classes(remove='p-4')  # HIDE
+        # with ui.column().classes('h-screen w-full bg-gray-400 justify-between'):
+        with ui.column().classes('h-full w-full bg-gray-400 justify-between'):  # HIDE
+            ui.label('top left')
+            ui.label('bottom right').classes('self-end')

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

+ 46 - 0
website/more_documentation/scroll_area_documentation.py

@@ -0,0 +1,46 @@
+from nicegui import ui
+
+from ..documentation_tools import text_demo
+
+
+def main_demo() -> None:
+    with ui.row():
+        with ui.card().classes('w-32 h-32'):
+            with ui.scroll_area():
+                ui.label('I scroll. ' * 20)
+        with ui.card().classes('w-32 h-32'):
+            ui.label('I will not scroll. ' * 10)
+
+
+def more() -> None:
+
+    @text_demo('Handling Scroll Events', '''
+        You can use the `on_scroll` argument in `ui.scroll_area` to handle scroll events.
+        The callback receives a `ScrollEventArguments` object with the following attributes:
+
+        - `sender`: the scroll area that generated the event
+        - `client`: the matching client
+        - additional arguments as described in [Quasar's documentation for the ScrollArea API](https://quasar.dev/vue-components/scroll-area/#qscrollarea-api)
+    ''')
+    def scroll_events():
+        position = ui.number('scroll position:').props('readonly')
+        with ui.card().classes('w-32 h-32'):
+            with ui.scroll_area(on_scroll=lambda e: position.set_value(e.vertical_percentage)):
+                ui.label('I scroll. ' * 20)
+
+    @text_demo('Setting the scroll position', '''
+        You can use `scroll_to` to programmatically set the scroll position.
+        This can be useful for navigation or synchronization of multiple scroll areas.
+    ''')
+    def scroll_events():
+        ui.number('position', value=0, min=0, max=1, step=0.1,
+                  on_change=lambda e: area1.scroll_to(percent=e.value)).classes('w-32')
+
+        with ui.row():
+            with ui.card().classes('w-32 h-48'):
+                with ui.scroll_area(on_scroll=lambda e: area2.scroll_to(percent=e.vertical_percentage)) as area1:
+                    ui.label('I scroll. ' * 20)
+
+            with ui.card().classes('w-32 h-48'):
+                with ui.scroll_area() as area2:
+                    ui.label('I scroll. ' * 20)

+ 7 - 0
website/more_documentation/separator_documentation.py

@@ -0,0 +1,7 @@
+from nicegui import ui
+
+
+def main_demo() -> None:
+    ui.label('text above')
+    ui.separator()
+    ui.label('text below')

+ 25 - 5
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"
   },
   {
@@ -139,6 +139,11 @@
     "content": "A thumbnail gallery where each image can be clicked to enlarge",
     "url": "https://github.com/zauberzeug/nicegui/tree/main/examples/lightbox/main.py"
   },
+  {
+    "title": "Example: ROS2",
+    "content": "Using NiceGUI as web interface for a ROS2 robot",
+    "url": "https://github.com/zauberzeug/nicegui/tree/main/examples/ros2/"
+  },
   {
     "title": "Basic Elements",
     "content": "This is **Markdown**.",
@@ -316,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"
   },
   {
@@ -516,7 +521,7 @@
   },
   {
     "title": "Text Input",
-    "content": "This element is based on Quasar's QInput <https://quasar.dev/vue-components/input>_ component.  The on_change event is called on every keystroke and the value updates accordingly. If you want to wait until the user confirms the input, you can register a custom event callback, e.g. ui.input(...).on('keydown.enter', ...) or ui.input(...).on('blur', ...).  You can use the validation parameter to define a dictionary of validation rules. The key of the first rule that fails will be displayed as an error message.  :param label: displayed label for the text input :param placeholder: text to show if no value is entered :param value: the current value of the text input :param password: whether to hide the input (default: False) :param password_toggle_button: whether to show a button to toggle the password visibility (default: False) :param on_change: callback to execute when the value changes :param autocomplete: optional list of strings for autocompletion :param validation: dictionary of validation rules, e.g. `'Too long!': lambda value: len(value) < 3` set_autocomplete Set the autocomplete list.",
+    "content": "This element is based on Quasar's QInput <https://quasar.dev/vue-components/input>_ component.  The on_change event is called on every keystroke and the value updates accordingly. If you want to wait until the user confirms the input, you can register a custom event callback, e.g. ui.input(...).on('keydown.enter', ...) or ui.input(...).on('blur', ...).  You can use the validation parameter to define a dictionary of validation rules. The key of the first rule that fails will be displayed as an error message.  :param label: displayed label for the text input :param placeholder: text to show if no value is entered :param value: the current value of the text input :param password: whether to hide the input (default: False) :param password_toggle_button: whether to show a button to toggle the password visibility (default: False) :param on_change: callback to execute when the value changes :param autocomplete: optional list of strings for autocompletion :param validation: dictionary of validation rules, e.g. `'Too long!': lambda value: len(value) < 3` set_autocomplete Set the autocomplete list.on_value_change",
     "url": "/documentation/input"
   },
   {
@@ -541,7 +546,7 @@
   },
   {
     "title": "Menu",
-    "content": "Creates a menu. The menu should be placed inside the element where it should be shown.  :param value: whether the menu is already opened (default: False) open close",
+    "content": "Creates a menu. The menu should be placed inside the element where it should be shown.  :param value: whether the menu is already opened (default: False) open Open the menu.close Close the menu.toggle Toggle the menu.",
     "url": "/documentation/menu"
   },
   {
@@ -704,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.",
@@ -831,7 +841,7 @@
   },
   {
     "title": "Dropdown Selection",
-    "content": "The options can be specified as a list of values, or as a dictionary mapping values to labels. After manipulating the options, call update() to update the options in the UI.  :param options: a list ['value1', ...] or dictionary 'value1':'label1', ... specifying the options :param value: the initial value :param on_change: callback to execute when selection changes :param with_input: whether to show an input field to filter the options :param multiple: whether to allow multiple selections on_filter",
+    "content": "The options can be specified as a list of values, or as a dictionary mapping values to labels. After manipulating the options, call update() to update the options in the UI.  :param options: a list ['value1', ...] or dictionary 'value1':'label1', ... specifying the options :param value: the initial value :param on_change: callback to execute when selection changes :param with_input: whether to show an input field to filter the options :param multiple: whether to allow multiple selections :param clearable: whether to add a button to clear the selection on_filter",
     "url": "/documentation/select"
   },
   {
@@ -844,6 +854,11 @@
     "content": "You can activate multiple to allow the selection of more than one item.",
     "url": "/documentation/select#multi_selection"
   },
+  {
+    "title": "Separator",
+    "content": "A separator for cards, menus and other component containers. Similar to HTML's <hr> tag.",
+    "url": "/documentation/separator"
+  },
   {
     "title": "Badge",
     "content": "A badge element wrapping Quasar's QBadge <https://quasar.dev/vue-components/badge>_ component.  :param text: the initial value of the text field :param color: the color name for component (either a Quasar, Tailwind, or CSS color or None, default: \"primary\") :param text_color: text color (either a Quasar, Tailwind, or CSS color or None, default: None) :param outline: use 'outline' design (colored text and borders only) (default: False)",
@@ -1029,6 +1044,11 @@
     "content": "Create a log view that allows to add new lines without re-transmitting the whole history to the client.  :param max_lines: maximum number of lines before dropping oldest ones (default: None) push clear Clear the log",
     "url": "/documentation/log"
   },
+  {
+    "title": "Log: Attach to a logger",
+    "content": "You can attach a ui.log element to a Python logger object so that log messages are pushed to the log element.",
+    "url": "/documentation/log#attach_to_a_logger"
+  },
   {
     "title": "Line Plot",
     "content": "Create a line plot using pyplot. The push method provides live updating when utilized in combination with ui.timer.  :param n: number of lines :param limit: maximum number of datapoints per line (new points will displace the oldest) :param update_every: update plot only after pushing new data multiple times to save CPU and bandwidth :param close: whether the figure should be closed after exiting the context; set to False if you want to update it later (default: True) :param kwargs: arguments like figsize which should be passed to pyplot.figure <https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.figure.html>_ with_legend push",