浏览代码

Merge branch 'main' into v1.3

# Conflicts:
#	nicegui/templates/index.html
Falko Schindler 1 年之前
父节点
当前提交
77bfb05454

+ 3 - 3
CITATION.cff

@@ -8,7 +8,7 @@ authors:
   given-names: Rodja
   given-names: Rodja
   orcid: https://orcid.org/0009-0009-4735-6227
   orcid: https://orcid.org/0009-0009-4735-6227
 title: 'NiceGUI: Web-based user interfaces with Python. The nice way.'
 title: 'NiceGUI: Web-based user interfaces with Python. The nice way.'
-version: v1.2.22
-date-released: '2023-06-24'
+version: v1.2.23
+date-released: '2023-06-26'
 url: https://github.com/zauberzeug/nicegui
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.8076547
+doi: 10.5281/zenodo.8083457

+ 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 "$@"

+ 1 - 0
main.py

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

+ 5 - 1
nicegui/elements/image.js

@@ -1,6 +1,10 @@
 export default {
 export default {
   template: `
   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">
       <template v-for="(_, slot) in $slots" v-slot:[slot]="slotProps">
         <slot :name="slot" v-bind="slotProps || {}" />
         <slot :name="slot" v-bind="slotProps || {}" />
       </template>
       </template>

+ 5 - 0
nicegui/elements/input.js

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

+ 2 - 2
nicegui/elements/joystick.py

@@ -43,8 +43,8 @@ class Joystick(Element):
                 handle_event(on_move, JoystickEventArguments(sender=self,
                 handle_event(on_move, JoystickEventArguments(sender=self,
                                                              client=self.client,
                                                              client=self.client,
                                                              action='move',
                                                              action='move',
-                                                             x=msg['args']['data']['vector']['x'],
-                                                             y=msg['args']['data']['vector']['y']))
+                                                             x=float(msg['args']['data']['vector']['x']),
+                                                             y=float(msg['args']['data']['vector']['y'])))
 
 
         def handle_end() -> None:
         def handle_end() -> None:
             self.active = False
             self.active = False

+ 6 - 0
nicegui/elements/menu.py

@@ -20,11 +20,17 @@ class Menu(ValueElement):
         super().__init__(tag='q-menu', value=value, on_value_change=None)
         super().__init__(tag='q-menu', value=value, on_value_change=None)
 
 
     def open(self) -> None:
     def open(self) -> None:
+        """Open the menu."""
         self.value = True
         self.value = True
 
 
     def close(self) -> None:
     def close(self) -> None:
+        """Close the menu."""
         self.value = False
         self.value = False
 
 
+    def toggle(self) -> None:
+        """Toggle the menu."""
+        self.value = not self.value
+
 
 
 class MenuItem(TextElement):
 class MenuItem(TextElement):
 
 

+ 11 - 6
nicegui/elements/select.js

@@ -1,12 +1,17 @@
 export default {
 export default {
   props: ["options"],
   props: ["options"],
   template: `
   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() {
   data() {
     return {
     return {
       initialOptions: this.options,
       initialOptions: this.options,

+ 5 - 1
nicegui/elements/table.js

@@ -1,6 +1,10 @@
 export default {
 export default {
   template: `
   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">
       <template v-for="(_, slot) in $slots" v-slot:[slot]="slotProps">
         <slot :name="slot" v-bind="slotProps || {}" />
         <slot :name="slot" v-bind="slotProps || {}" />
       </template>
       </template>

+ 7 - 7
nicegui/elements/upload.js

@@ -1,9 +1,12 @@
 export default {
 export default {
   template: `
   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>
     </q-uploader>
   `,
   `,
   mounted() {
   mounted() {
@@ -16,9 +19,6 @@ export default {
     compute_url() {
     compute_url() {
       this.computed_url = (this.url.startsWith("/") ? window.path_prefix : "") + this.url;
       this.computed_url = (this.url.startsWith("/") ? window.path_prefix : "") + this.url;
     },
     },
-    reset() {
-      this.$refs.uploader.reset();
-    },
   },
   },
   props: {
   props: {
     url: String,
     url: String,

+ 5 - 1
nicegui/storage.py

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

+ 9 - 1
nicegui/templates/index.html

@@ -213,7 +213,15 @@
               this.elements[element.id] = element;
               this.elements[element.id] = element;
             }
             }
           });
           });
-          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("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("open", (msg) => (location.href = msg.startsWith('/') ? "{{ prefix | safe }}" + msg : msg));
           window.socket.on("download", (msg) => download(msg.url, msg.filename));
           window.socket.on("download", (msg) => download(msg.url, msg.filename));

+ 17 - 0
tests/test_input.py

@@ -141,3 +141,20 @@ def test_update_input(screen: Screen):
     input.value = 'Pete'
     input.value = 'Pete'
     screen.wait(0.5)
     screen.wait(0.5)
     assert element.get_attribute('value') == 'Pete'
     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

+ 2 - 1
website/build_search_index.py

@@ -132,10 +132,11 @@ class MainVisitor(ast.NodeVisitor):
         if function_name == 'example_link':
         if function_name == 'example_link':
             title = ast_string_node_to_string(node.args[0])
             title = ast_string_node_to_string(node.args[0])
             name = name = title.lower().replace(' ', '_')
             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({
             documents.append({
                 'title': 'Example: ' + title,
                 'title': 'Example: ' + title,
                 'content': ast_string_node_to_string(node.args[1]),
                 '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}',
             })
             })
 
 
 
 

+ 18 - 3
website/static/search_index.json

@@ -139,6 +139,11 @@
     "content": "A thumbnail gallery where each image can be clicked to enlarge",
     "content": "A thumbnail gallery where each image can be clicked to enlarge",
     "url": "https://github.com/zauberzeug/nicegui/tree/main/examples/lightbox/main.py"
     "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",
     "title": "Basic Elements",
     "content": "This is **Markdown**.",
     "content": "This is **Markdown**.",
@@ -516,7 +521,7 @@
   },
   },
   {
   {
     "title": "Text Input",
     "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"
     "url": "/documentation/input"
   },
   },
   {
   {
@@ -541,7 +546,7 @@
   },
   },
   {
   {
     "title": "Menu",
     "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"
     "url": "/documentation/menu"
   },
   },
   {
   {
@@ -831,7 +836,7 @@
   },
   },
   {
   {
     "title": "Dropdown Selection",
     "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"
     "url": "/documentation/select"
   },
   },
   {
   {
@@ -844,6 +849,11 @@
     "content": "You can activate multiple to allow the selection of more than one item.",
     "content": "You can activate multiple to allow the selection of more than one item.",
     "url": "/documentation/select#multi_selection"
     "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",
     "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)",
     "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 +1039,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",
     "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"
     "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",
     "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",
     "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",