Browse Source

Merge branch 'main' into frankvp11/main

Falko Schindler 1 year ago
parent
commit
b085b18da4
51 changed files with 559 additions and 118 deletions
  1. 3 3
      CITATION.cff
  2. 0 3
      deploy.sh
  3. 2 6
      examples/generate_pdf/main.py
  4. 1 1
      nicegui/client.py
  5. 2 2
      nicegui/elements/aggrid.js
  6. 17 9
      nicegui/elements/aggrid.py
  7. 2 2
      nicegui/elements/audio.py
  8. 1 3
      nicegui/elements/card.py
  9. 7 0
      nicegui/elements/echart.js
  10. 19 0
      nicegui/elements/echart.py
  11. 7 5
      nicegui/elements/expansion.py
  12. 1 1
      nicegui/elements/input.py
  13. 23 11
      nicegui/elements/interactive_image.js
  14. 16 3
      nicegui/elements/interactive_image.py
  15. 9 0
      nicegui/elements/json_editor.js
  16. 19 0
      nicegui/elements/json_editor.py
  17. 8 7
      nicegui/elements/mixins/validation_element.py
  18. 1 1
      nicegui/elements/mixins/value_element.py
  19. 2 0
      nicegui/elements/notification.py
  20. 1 1
      nicegui/elements/number.py
  21. 36 0
      nicegui/elements/plotly.vue
  22. 58 12
      nicegui/elements/query.py
  23. 1 1
      nicegui/elements/scene.js
  24. 13 0
      nicegui/elements/space.py
  25. 1 1
      nicegui/elements/table.py
  26. 1 1
      nicegui/elements/textarea.py
  27. 2 2
      nicegui/elements/video.py
  28. 1 1
      nicegui/events.py
  29. 6 5
      nicegui/page_layout.py
  30. 4 0
      nicegui/static/nicegui.css
  31. 19 6
      nicegui/storage.py
  32. 3 1
      nicegui/ui.py
  33. 17 3
      tests/conftest.py
  34. 26 5
      tests/screen.py
  35. 5 5
      tests/test_aggrid.py
  36. 16 0
      tests/test_echart.py
  37. 2 0
      tests/test_input.py
  38. 20 0
      tests/test_json_editor.py
  39. 1 1
      tests/test_scene.py
  40. 5 8
      tests/test_storage.py
  41. 3 2
      tests/test_timer.py
  42. 2 2
      website/documentation/content/aggrid_documentation.py
  43. 28 0
      website/documentation/content/echart_documentation.py
  44. 18 0
      website/documentation/content/grid_documentation.py
  45. 15 2
      website/documentation/content/interactive_image_documentation.py
  46. 28 0
      website/documentation/content/json_editor_documentation.py
  47. 32 0
      website/documentation/content/plotly_documentation.py
  48. 3 0
      website/documentation/content/query_documentation.py
  49. 3 2
      website/documentation/content/section_page_layout.py
  50. 24 0
      website/documentation/content/space_documentation.py
  51. 25 0
      website/documentation/content/tabs_documentation.py

+ 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.4.6
-date-released: '2023-12-18'
+version: v1.4.7
+date-released: '2023-12-26'
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.10402969
+doi: 10.5281/zenodo.10431884

+ 0 - 3
deploy.sh

@@ -1,5 +1,2 @@
 
-pushd website
-./build_search_index.py
-popd
 fly deploy --wait-timeout 360 --build-arg VERSION=$(git describe --abbrev=0 --tags --match 'v*' 2>/dev/null | sed 's/^v//' || echo '0.0.0') 

+ 2 - 6
examples/generate_pdf/main.py

@@ -1,13 +1,10 @@
 #!/usr/bin/env python3
 from io import BytesIO
-from pathlib import Path
 
 import cairo
 
 from nicegui import ui
 
-PDF_PATH = Path('output.pdf')
-
 
 def generate_svg() -> str:
     output = BytesIO()
@@ -25,7 +22,7 @@ def generate_pdf() -> bytes:
     return output.getvalue()
 
 
-def draw(surface: cairo.SVGSurface) -> None:
+def draw(surface: cairo.Surface) -> None:
     context = cairo.Context(surface)
     context.select_font_face('Arial', cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
     context.set_font_size(20)
@@ -37,7 +34,6 @@ def draw(surface: cairo.SVGSurface) -> None:
 
 def update() -> None:
     preview.content = generate_svg()
-    PDF_PATH.write_bytes(generate_pdf())
 
 
 with ui.row():
@@ -46,6 +42,6 @@ with ui.row():
         email = ui.input('E-Mail', placeholder='Enter your E-Mail address', on_change=update)
     preview = ui.html().classes('border-2 border-gray-500')
     update()
-    ui.button('Download PDF', on_click=lambda: ui.download(PDF_PATH)).bind_visibility_from(name, 'value')
+    ui.button('PDF', on_click=lambda: ui.download(generate_pdf(), 'output.pdf')).bind_visibility_from(name, 'value')
 
 ui.run()

+ 1 - 1
nicegui/client.py

@@ -252,7 +252,7 @@ class Client:
         """Forward an event to the corresponding element."""
         with self:
             sender = self.elements.get(msg['id'])
-            if sender:
+            if sender is not None:
                 msg['args'] = [None if arg is None else json.loads(arg) for arg in msg.get('args', [])]
                 if len(msg['args']) == 1:
                     msg['args'] = msg['args'][0]

+ 2 - 2
nicegui/elements/aggrid.js

@@ -47,10 +47,10 @@ export default {
       this.grid = new agGrid.Grid(this.$el, this.gridOptions);
       this.gridOptions.api.addGlobalListener(this.handle_event);
     },
-    call_api_method(name, ...args) {
+    run_grid_method(name, ...args) {
       return this.gridOptions.api[name](...args);
     },
-    call_column_api_method(name, ...args) {
+    run_column_method(name, ...args) {
       return this.gridOptions.columnApi[name](...args);
     },
     handle_event(type, args) {

+ 17 - 9
nicegui/elements/aggrid.py

@@ -25,7 +25,7 @@ class AgGrid(Element, component='aggrid.js', libraries=['lib/aggrid/ag-grid-comm
 
         An element to create a grid using `AG Grid <https://www.ag-grid.com/>`_.
 
-        The methods `call_api_method` and `call_column_api_method` can be used to interact with the AG Grid instance on the client.
+        The methods `run_grid_method` and `run_column_method` can be used to interact with the AG Grid instance on the client.
 
         :param options: dictionary of AG Grid options
         :param html_columns: list of columns that should be rendered as HTML (default: `[]`)
@@ -41,7 +41,7 @@ class AgGrid(Element, component='aggrid.js', libraries=['lib/aggrid/ag-grid-comm
 
     @classmethod
     def from_pandas(cls,
-                    df: pd.DataFrame, *,
+                    df: 'pd.DataFrame', *,
                     theme: str = 'balham',
                     auto_size_columns: bool = True,
                     options: Dict = {}) -> Self:
@@ -87,7 +87,11 @@ class AgGrid(Element, component='aggrid.js', libraries=['lib/aggrid/ag-grid-comm
         self.run_method('update_grid')
 
     def call_api_method(self, name: str, *args, timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse:
-        """Call an AG Grid API method.
+        """DEPRECATED: Use `run_grid_method` instead."""
+        return self.run_grid_method(name, *args, timeout=timeout, check_interval=check_interval)
+
+    def run_grid_method(self, name: str, *args, timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse:
+        """Run an AG Grid API method.
 
         See `AG Grid API <https://www.ag-grid.com/javascript-data-grid/grid-api/>`_ for a list of methods.
 
@@ -101,11 +105,15 @@ class AgGrid(Element, component='aggrid.js', libraries=['lib/aggrid/ag-grid-comm
 
         :return: AwaitableResponse that can be awaited to get the result of the method call
         """
-        return self.run_method('call_api_method', name, *args, timeout=timeout, check_interval=check_interval)
+        return self.run_method('run_grid_method', name, *args, timeout=timeout, check_interval=check_interval)
+
+    def call_column_method(self, name: str, *args, timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse:
+        """DEPRECATED: Use `run_column_method` instead."""
+        return self.run_column_method(name, *args, timeout=timeout, check_interval=check_interval)
 
-    def call_column_api_method(self, name: str, *args,
-                               timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse:
-        """Call an AG Grid Column API method.
+    def run_column_method(self, name: str, *args,
+                          timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse:
+        """Run an AG Grid Column API method.
 
         See `AG Grid Column API <https://www.ag-grid.com/javascript-data-grid/column-api/>`_ for a list of methods.
 
@@ -119,7 +127,7 @@ class AgGrid(Element, component='aggrid.js', libraries=['lib/aggrid/ag-grid-comm
 
         :return: AwaitableResponse that can be awaited to get the result of the method call
         """
-        return self.run_method('call_column_api_method', name, *args, timeout=timeout, check_interval=check_interval)
+        return self.run_method('run_column_method', name, *args, timeout=timeout, check_interval=check_interval)
 
     async def get_selected_rows(self) -> List[Dict]:
         """Get the currently selected rows.
@@ -130,7 +138,7 @@ class AgGrid(Element, component='aggrid.js', libraries=['lib/aggrid/ag-grid-comm
 
         :return: list of selected row data
         """
-        result = await self.call_api_method('getSelectedRows')
+        result = await self.run_grid_method('getSelectedRows')
         return cast(List[Dict], result)
 
     async def get_selected_row(self) -> Optional[Dict]:

+ 2 - 2
nicegui/elements/audio.py

@@ -1,7 +1,7 @@
 from pathlib import Path
 from typing import Union
 
-from .. import core
+from .. import core, helpers
 from ..element import Element
 
 
@@ -27,7 +27,7 @@ class Audio(Element, component='audio.js'):
         for a list of events you can subscribe to using the generic event subscription `on()`.
         """
         super().__init__()
-        if Path(src).is_file():
+        if helpers.is_file(src):
             src = core.app.add_media_file(local_file=src)
         self._props['src'] = src
         self._props['controls'] = controls

+ 1 - 3
nicegui/elements/card.py

@@ -22,9 +22,7 @@ class Card(Element):
 
     def tight(self) -> Self:
         """Remove padding and gaps between nested elements."""
-        self._classes.clear()
-        self._style.clear()
-        return self
+        return self.classes('nicegui-card-tight')
 
 
 class CardSection(Element):

+ 7 - 0
nicegui/elements/echart.js

@@ -19,6 +19,13 @@ export default {
       convertDynamicProperties(this.options, true);
       this.chart.setOption(this.options, { notMerge: this.chart.options?.series.length != this.options.series.length });
     },
+    run_chart_method(name, ...args) {
+      if (name.startsWith(":")) {
+        name = name.slice(1);
+        args = args.map((arg) => new Function("return " + arg)());
+      }
+      return this.chart[name](...args);
+    },
   },
   props: {
     options: Object,

+ 19 - 0
nicegui/elements/echart.py

@@ -1,5 +1,6 @@
 from typing import Callable, Dict, Optional
 
+from ..awaitable_response import AwaitableResponse
 from ..element import Element
 from ..events import EChartPointClickEventArguments, GenericEventArguments, handle_event
 
@@ -55,3 +56,21 @@ class EChart(Element, component='echart.js', libraries=['lib/echarts/echarts.min
     def update(self) -> None:
         super().update()
         self.run_method('update_chart')
+
+    def run_chart_method(self, name: str, *args, timeout: float = 1,
+                         check_interval: float = 0.01) -> AwaitableResponse:
+        """Run a method of the JSONEditor instance.
+
+        See the `ECharts documentation <https://echarts.apache.org/en/api.html#echartsInstance>`_ for a list of methods.
+
+        If the function is awaited, the result of the method call is returned.
+        Otherwise, the method is executed without waiting for a response.
+
+        :param name: name of the method (a prefix ":" indicates that the arguments are JavaScript expressions)
+        :param args: arguments to pass to the method (Python objects or JavaScript expressions)
+        :param timeout: timeout in seconds (default: 1 second)
+        :param check_interval: interval in seconds to check for a response (default: 0.01 seconds)
+
+        :return: AwaitableResponse that can be awaited to get the result of the method call
+        """
+        return self.run_method('run_chart_method', name, *args, timeout=timeout, check_interval=check_interval)

+ 7 - 5
nicegui/elements/expansion.py

@@ -1,13 +1,14 @@
 from typing import Any, Callable, Optional
 
 from .mixins.disableable_element import DisableableElement
+from .mixins.text_element import TextElement
 from .mixins.value_element import ValueElement
 
 
-class Expansion(ValueElement, DisableableElement):
+class Expansion(TextElement, ValueElement, DisableableElement):
 
     def __init__(self,
-                 text: Optional[str] = None, *,
+                 text: str = '', *,
                  icon: Optional[str] = None,
                  value: bool = False,
                  on_value_change: Optional[Callable[..., Any]] = None
@@ -21,9 +22,7 @@ class Expansion(ValueElement, DisableableElement):
         :param value: whether the expansion should be opened on creation (default: `False`)
         :param on_value_change: callback to execute when value changes
         """
-        super().__init__(tag='q-expansion-item', value=value, on_value_change=on_value_change)
-        if text is not None:
-            self._props['label'] = text
+        super().__init__(tag='q-expansion-item', text=text, value=value, on_value_change=on_value_change)
         self._props['icon'] = icon
         self._classes.append('nicegui-expansion')
 
@@ -34,3 +33,6 @@ class Expansion(ValueElement, DisableableElement):
     def close(self) -> None:
         """Close the expansion."""
         self.value = False
+
+    def _text_to_model_text(self, text: str) -> None:
+        self._props['label'] = text

+ 1 - 1
nicegui/elements/input.py

@@ -17,7 +17,7 @@ class Input(ValidationElement, DisableableElement, component='input.js'):
                  password_toggle_button: bool = False,
                  on_change: Optional[Callable[..., Any]] = None,
                  autocomplete: Optional[List[str]] = None,
-                 validation: Dict[str, Callable[..., bool]] = {}) -> None:
+                 validation: Optional[Dict[str, Callable[..., bool]]] = None) -> None:
         """Text Input
 
         This element is based on Quasar's `QInput <https://quasar.dev/vue-components/input>`_ component.

+ 23 - 11
nicegui/elements/interactive_image.js

@@ -1,17 +1,17 @@
 export default {
   template: `
-    <div style="position:relative">
+    <div :style="{ position: 'relative', aspectRatio: size ? size[0] / size[1] : undefined }">
       <img
         ref="img"
         :src="computed_src"
-        style="width:100%; height:100%;"
+        :style="{ width: '100%', height: '100%', opacity: src ? 1 : 0 }"
         @load="onImageLoaded"
         v-on="onCrossEvents"
         v-on="onUserEvents"
         draggable="false"
       />
       <svg style="position:absolute;top:0;left:0;pointer-events:none" :viewBox="viewBox">
-        <g v-if="cross" :style="{ display: cssDisplay }">
+        <g v-if="cross" :style="{ display: showCross ? 'block' : 'none' }">
           <line :x1="x" y1="0" :x2="x" y2="100%" stroke="black" />
           <line x1="0" :y1="y" x2="100%" :y2="y" stroke="black" />
         </g>
@@ -23,9 +23,11 @@ export default {
   data() {
     return {
       viewBox: "0 0 0 0",
+      loaded_image_width: 0,
+      loaded_image_height: 0,
       x: 100,
       y: 100,
-      cssDisplay: "none",
+      showCross: false,
       computed_src: undefined,
       waiting_source: undefined,
       loading: false,
@@ -60,19 +62,28 @@ export default {
         this.computed_src = new_src;
         this.loading = true;
       }
+      if (!this.src && this.size) {
+        this.viewBox = `0 0 ${this.size[0]} ${this.size[1]}`;
+      }
     },
     updateCrossHair(e) {
-      this.x = (e.offsetX * e.target.naturalWidth) / e.target.clientWidth;
-      this.y = (e.offsetY * e.target.naturalHeight) / e.target.clientHeight;
+      const width = this.src ? this.loaded_image_width : this.size ? this.size[0] : 1;
+      const height = this.src ? this.loaded_image_height : this.size ? this.size[1] : 1;
+      this.x = (e.offsetX * width) / e.target.clientWidth;
+      this.y = (e.offsetY * height) / e.target.clientHeight;
     },
     onImageLoaded(e) {
-      this.viewBox = `0 0 ${e.target.naturalWidth} ${e.target.naturalHeight}`;
+      this.loaded_image_width = e.target.naturalWidth;
+      this.loaded_image_height = e.target.naturalHeight;
+      this.viewBox = `0 0 ${this.loaded_image_width} ${this.loaded_image_height}`;
     },
     onMouseEvent(type, e) {
+      const width = this.src ? this.loaded_image_width : this.size ? this.size[0] : 1;
+      const height = this.src ? this.loaded_image_height : this.size ? this.size[1] : 1;
       this.$emit("mouse", {
         mouse_event_type: type,
-        image_x: (e.offsetX * e.target.naturalWidth) / e.target.clientWidth,
-        image_y: (e.offsetY * e.target.naturalHeight) / e.target.clientHeight,
+        image_x: (e.offsetX * width) / e.target.clientWidth,
+        image_y: (e.offsetY * height) / e.target.clientHeight,
         button: e.button,
         buttons: e.buttons,
         altKey: e.altKey,
@@ -86,8 +97,8 @@ export default {
     onCrossEvents() {
       if (!this.cross) return {};
       return {
-        mouseenter: () => (this.cssDisplay = "block"),
-        mouseleave: () => (this.cssDisplay = "none"),
+        mouseenter: () => (this.showCross = true),
+        mouseleave: () => (this.showCross = false),
         mousemove: (event) => this.updateCrossHair(event),
       };
     },
@@ -102,6 +113,7 @@ export default {
   props: {
     src: String,
     content: String,
+    size: Object,
     events: Array,
     cross: Boolean,
     t: String,

+ 16 - 3
nicegui/elements/interactive_image.py

@@ -2,7 +2,7 @@ from __future__ import annotations
 
 import time
 from pathlib import Path
-from typing import Any, Callable, List, Optional, Union, cast
+from typing import Any, Callable, List, Optional, Tuple, Union, cast
 
 from .. import optional_features
 from ..events import GenericEventArguments, MouseEventArguments, handle_event
@@ -24,6 +24,7 @@ class InteractiveImage(SourceElement, ContentElement, component='interactive_ima
     def __init__(self,
                  source: Union[str, Path, 'PIL_Image'] = '', *,
                  content: str = '',
+                 size: Optional[Tuple[int, int]] = None,
                  on_mouse: Optional[Callable[..., Any]] = None,
                  events: List[str] = ['click'],
                  cross: bool = False,
@@ -36,15 +37,27 @@ class InteractiveImage(SourceElement, ContentElement, component='interactive_ima
         Thereby repeatedly updating the image source will automatically adapt to the available bandwidth.
         See `OpenCV Webcam <https://github.com/zauberzeug/nicegui/tree/main/examples/opencv_webcam/main.py>`_ for an example.
 
-        :param source: the source of the image; can be an URL, local file path or a base64 string
+        The mouse event handler is called with mouse event arguments containing
+
+        - `type` (the name of the JavaScript event),
+        - `image_x` and `image_y` (image coordinates in pixels),
+        - `button` and `buttons` (mouse button numbers from the JavaScript event), as well as
+        - `alt`, `ctrl`, `meta`, and `shift` (modifier keys from the JavaScript event).
+
+        You can also pass a tuple of width and height instead of an image source.
+        This will create an empty image with the given size.
+
+        :param source: the source of the image; can be an URL, local file path, a base64 string or just an image size
         :param content: SVG content which should be overlaid; viewport has the same dimensions as the image
-        :param on_mouse: callback for mouse events (yields `type`, `image_x` and `image_y`)
+        :param size: size of the image (width, height) in pixels; only used if `source` is not set
+        :param on_mouse: callback for mouse events (contains image coordinates `image_x` and `image_y` in pixels)
         :param events: list of JavaScript events to subscribe to (default: `['click']`)
         :param cross: whether to show crosshairs (default: `False`)
         """
         super().__init__(source=source, content=content)
         self._props['events'] = events
         self._props['cross'] = cross
+        self._props['size'] = size
 
         def handle_mouse(e: GenericEventArguments) -> None:
             if on_mouse is None:

+ 9 - 0
nicegui/elements/json_editor.js

@@ -31,6 +31,15 @@ export default {
         this.editor.destroy();
       }
     },
+    run_editor_method(name, ...args) {
+      if (this.editor) {
+        if (name.startsWith(":")) {
+          name = name.slice(1);
+          args = args.map((arg) => new Function("return " + arg)());
+        }
+        return this.editor[name](...args);
+      }
+    },
   },
   props: {
     properties: Object,

+ 19 - 0
nicegui/elements/json_editor.py

@@ -1,5 +1,6 @@
 from typing import Callable, Dict, Optional
 
+from ..awaitable_response import AwaitableResponse
 from ..element import Element
 from ..events import GenericEventArguments, JsonEditorChangeEventArguments, JsonEditorSelectEventArguments, handle_event
 
@@ -42,3 +43,21 @@ class JsonEditor(Element, component='json_editor.js', exposed_libraries=['lib/va
     def update(self) -> None:
         super().update()
         self.run_method('update_editor')
+
+    def run_editor_method(self, name: str, *args, timeout: float = 1,
+                          check_interval: float = 0.01) -> AwaitableResponse:
+        """Run a method of the JSONEditor instance.
+
+        See the `JSONEditor README <https://github.com/josdejong/svelte-jsoneditor/>`_ for a list of methods.
+
+        If the function is awaited, the result of the method call is returned.
+        Otherwise, the method is executed without waiting for a response.
+
+        :param name: name of the method (a prefix ":" indicates that the arguments are JavaScript expressions)
+        :param args: arguments to pass to the method (Python objects or JavaScript expressions)
+        :param timeout: timeout in seconds (default: 1 second)
+        :param check_interval: interval in seconds to check for a response (default: 0.01 seconds)
+
+        :return: AwaitableResponse that can be awaited to get the result of the method call
+        """
+        return self.run_method('run_editor_method', name, *args, timeout=timeout, check_interval=check_interval)

+ 8 - 7
nicegui/elements/mixins/validation_element.py

@@ -5,9 +5,9 @@ from .value_element import ValueElement
 
 class ValidationElement(ValueElement):
 
-    def __init__(self, validation: Dict[str, Callable[..., bool]], **kwargs: Any) -> None:
+    def __init__(self, validation: Optional[Dict[str, Callable[..., bool]]], **kwargs: Any) -> None:
         super().__init__(**kwargs)
-        self.validation = validation
+        self.validation = validation if validation is not None else {}
         self._error: Optional[str] = None
 
     @property
@@ -15,16 +15,17 @@ class ValidationElement(ValueElement):
         """The latest error message from the validation functions."""
         return self._error
 
-    def validate(self) -> None:
+    def validate(self) -> bool:
         """Validate the current value and set the error message if necessary."""
         for message, check in self.validation.items():
             if not check(self.value):
                 self._error = message
                 self.props(f'error error-message="{message}"')
-                break
-        else:
-            self._error = None
-            self.props(remove='error')
+                return False
+
+        self._error = None
+        self.props(remove='error')
+        return True
 
     def _handle_value_change(self, value: Any) -> None:
         super()._handle_value_change(value)

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

@@ -20,10 +20,10 @@ class ValueElement(Element):
                  **kwargs: Any,
                  ) -> None:
         super().__init__(**kwargs)
+        self._send_update_on_value_change = True
         self.set_value(value)
         self._props[self.VALUE_PROP] = self._value_to_model_value(value)
         self._props['loopback'] = self.LOOPBACK
-        self._send_update_on_value_change = True
         self._change_handler = on_value_change
 
         def handle_change(e: GenericEventArguments) -> None:

+ 2 - 0
nicegui/elements/notification.py

@@ -50,6 +50,8 @@ class Notification(Element, component='notification.js'):
         :param type: optional type ("positive", "negative", "warning", "info" or "ongoing")
         :param color: optional color name
         :param multi_line: enable multi-line notifications
+        :param icon: optional name of an icon to be displayed in the notification (default: `None`)
+        :param spinner: display a spinner in the notification (default: False)
         :param timeout: optional timeout in seconds after which the notification is dismissed (default: 5.0)
 
         Note: You can pass additional keyword arguments according to `Quasar's Notify API <https://quasar.dev/quasar-plugins/notify#notify-api>`_.

+ 1 - 1
nicegui/elements/number.py

@@ -20,7 +20,7 @@ class Number(ValidationElement, DisableableElement):
                  suffix: Optional[str] = None,
                  format: Optional[str] = None,  # pylint: disable=redefined-builtin
                  on_change: Optional[Callable[..., Any]] = None,
-                 validation: Dict[str, Callable[..., bool]] = {},
+                 validation: Optional[Dict[str, Callable[..., bool]]] = None,
                  ) -> None:
         """Number Input
 

+ 36 - 0
nicegui/elements/plotly.vue

@@ -25,6 +25,42 @@ export default {
         Plotly.newPlot(this.$el.id, this.options.data, this.options.layout, options.config);
       }
 
+      // forward events
+      for (const name of [
+        // source: https://plotly.com/javascript/plotlyjs-events/
+        "plotly_click",
+        "plotly_legendclick",
+        "plotly_selecting",
+        "plotly_selected",
+        "plotly_hover",
+        "plotly_unhover",
+        "plotly_legenddoubleclick",
+        "plotly_restyle",
+        "plotly_relayout",
+        "plotly_webglcontextlost",
+        "plotly_afterplot",
+        "plotly_autosize",
+        "plotly_deselect",
+        "plotly_doubleclick",
+        "plotly_redraw",
+        "plotly_animated",
+      ]) {
+        this.$el.on(name, (event) => {
+          const args = {
+            ...event,
+            points: event?.points?.map((p) => ({
+              ...p,
+              fullData: undefined,
+              xaxis: undefined,
+              yaxis: undefined,
+            })),
+            xaxes: undefined,
+            yaxes: undefined,
+          };
+          this.$emit(name, args);
+        });
+      }
+
       // store last options
       this.last_options = options;
     },

+ 58 - 12
nicegui/elements/query.py

@@ -6,7 +6,7 @@ from .. import context
 from ..element import Element
 
 
-class Query(Element, component='query.js'):
+class QueryElement(Element, component='query.js'):
 
     def __init__(self, selector: str) -> None:
         super().__init__()
@@ -53,16 +53,62 @@ class Query(Element, component='query.js'):
         return self
 
 
-def query(selector: str) -> Query:
-    """Query Selector
+class 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')`).
+    def __init__(self, selector: str) -> None:
+        """Query Selector
+
+        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 context.get_client().elements.values():
+            if isinstance(element, QueryElement) and element._props['selector'] == selector:  # pylint: disable=protected-access
+                self.element = element
+                break
+        else:
+            self.element = QueryElement(selector)
+
+    def classes(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) \
+            -> Self:
+        """Apply, remove, or replace HTML classes.
 
-    :param selector: the CSS selector (e.g. "body", "#my-id", ".my-class", "div > p")
-    """
-    for element in context.get_client().elements.values():
-        if isinstance(element, Query) and element._props['selector'] == selector:  # pylint: disable=protected-access
-            return element
-    return Query(selector)
+        This allows modifying the look of the element or its layout using `Tailwind <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ classes.
+
+        Removing or replacing classes can be helpful if predefined classes are not desired.
+
+        :param add: whitespace-delimited string of classes
+        :param remove: whitespace-delimited string of classes to remove from the element
+        :param replace: whitespace-delimited string of classes to use instead of existing ones
+        """
+        self.element.classes(add, remove=remove, replace=replace)
+        return self
+
+    def style(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) \
+            -> Self:
+        """Apply, remove, or replace CSS definitions.
+
+        Removing or replacing styles can be helpful if the predefined style is not desired.
+
+        :param add: semicolon-separated list of styles to add to the element
+        :param remove: semicolon-separated list of styles to remove from the element
+        :param replace: semicolon-separated list of styles to use instead of existing ones
+        """
+        self.element.style(add, remove=remove, replace=replace)
+        return self
+
+    def props(self, add: Optional[str] = None, *, remove: Optional[str] = None) -> Self:
+        """Add or remove props.
+
+        This allows modifying the look of the element or its layout using `Quasar <https://quasar.dev/>`_ props.
+        Since props are simply applied as HTML attributes, they can be used with any HTML element.
+
+        Boolean properties are assumed ``True`` if no value is specified.
+
+        :param add: whitespace-delimited list of either boolean values or key=value pair to add
+        :param remove: whitespace-delimited list of property keys to remove
+        """
+        self.element.props(add, remove=remove)
+        return self

+ 1 - 1
nicegui/elements/scene.js

@@ -179,7 +179,7 @@ export default {
     this.texture_loader = new THREE.TextureLoader();
     this.stl_loader = new STLLoader();
 
-    const connectInterval = setInterval(async () => {
+    const connectInterval = setInterval(() => {
       if (window.socket.id === undefined) return;
       this.$emit("init", { socket_id: window.socket.id });
       clearInterval(connectInterval);

+ 13 - 0
nicegui/elements/space.py

@@ -0,0 +1,13 @@
+from ..element import Element
+
+
+class Space(Element):
+
+    def __init__(self) -> None:
+        """Space
+
+        This element is based on Quasar's `QSpace <https://quasar.dev/vue-components/space>`_ component.
+
+        Its purpose is to simply fill all available space inside of a flexbox element.
+        """
+        super().__init__('q-space')

+ 1 - 1
nicegui/elements/table.py

@@ -74,7 +74,7 @@ class Table(FilterElement, component='table.js'):
 
     @classmethod
     def from_pandas(cls,
-                    df: pd.DataFrame,
+                    df: 'pd.DataFrame',
                     row_key: str = 'id',
                     title: Optional[str] = None,
                     selection: Optional[Literal['single', 'multiple']] = None,

+ 1 - 1
nicegui/elements/textarea.py

@@ -10,7 +10,7 @@ class Textarea(Input, component='input.js'):
                  placeholder: Optional[str] = None,
                  value: str = '',
                  on_change: Optional[Callable[..., Any]] = None,
-                 validation: Dict[str, Callable[..., bool]] = {},
+                 validation: Optional[Dict[str, Callable[..., bool]]] = None,
                  ) -> None:
         """Textarea
 

+ 2 - 2
nicegui/elements/video.py

@@ -1,7 +1,7 @@
 from pathlib import Path
 from typing import Union
 
-from .. import core
+from .. import core, helpers
 from ..element import Element
 
 
@@ -27,7 +27,7 @@ class Video(Element, component='video.js'):
         for a list of events you can subscribe to using the generic event subscription `on()`.
         """
         super().__init__()
-        if Path(src).is_file():
+        if helpers.is_file(src):
             src = core.app.add_media_file(local_file=src)
         self._props['src'] = src
         self._props['controls'] = controls

+ 1 - 1
nicegui/events.py

@@ -181,7 +181,7 @@ class KeyboardKey:
     @property
     def enter(self) -> bool:
         """Whether the key is the enter key."""
-        return self.name == 'enter'
+        return self.name == 'Enter'
 
     @property
     def shift(self) -> bool:

+ 6 - 5
nicegui/page_layout.py

@@ -44,7 +44,7 @@ class Header(ValueElement):
         :param wrap: whether the header should wrap its content (default: `True`)
         :param add_scroll_padding: whether to automatically prevent link targets from being hidden behind the header (default: `True`)
         """
-        _check_current_slot()
+        _check_current_slot(self)
         with context.get_client().layout:
             super().__init__(tag='q-header', value=value, on_value_change=None)
         self._classes.append('nicegui-header')
@@ -108,7 +108,7 @@ class Drawer(Element):
         :param top_corner: whether the drawer expands into the top corner (default: `False`)
         :param bottom_corner: whether the drawer expands into the bottom corner (default: `False`)
         """
-        _check_current_slot()
+        _check_current_slot(self)
         with context.get_client().layout:
             super().__init__('q-drawer')
         if value is None:
@@ -227,7 +227,7 @@ class Footer(ValueElement):
         :param elevated: whether the footer should have a shadow (default: `False`)
         :param wrap: whether the footer should wrap its content (default: `True`)
         """
-        _check_current_slot()
+        _check_current_slot(self)
         with context.get_client().layout:
             super().__init__(tag='q-footer', value=value, on_value_change=None)
         self.classes('nicegui-footer')
@@ -270,8 +270,9 @@ class PageSticky(Element):
         self._props['offset'] = [x_offset, y_offset]
 
 
-def _check_current_slot() -> None:
+def _check_current_slot(element: Element) -> None:
     parent = context.get_slot().parent
     if parent != parent.client.content:
-        log.warning('Layout elements should not be nested but must be direct children of the page content. '
+        log.warning(f'Found top level layout element "{element.__class__.__name__}" inside element "{parent.__class__.__name__}". '
+                    'Top level layout elements should not be nested but must be direct children of the page content. '
                     'This will be raising an exception in NiceGUI 1.5')  # DEPRECATED

+ 4 - 0
nicegui/static/nicegui.css

@@ -42,6 +42,10 @@
 .nicegui-column {
   padding: 0;
 }
+.nicegui-card-tight {
+  padding: 0;
+  gap: 0;
+}
 
 /* original padding for some Quasar elements */
 .nicegui-step .q-stepper__nav {

+ 19 - 6
nicegui/storage.py

@@ -42,10 +42,11 @@ class ReadOnlyDict(MutableMapping):
 
 class PersistentDict(observables.ObservableDict):
 
-    def __init__(self, filepath: Path) -> None:
+    def __init__(self, filepath: Path, encoding: Optional[str] = None) -> None:
         self.filepath = filepath
+        self.encoding = encoding
         try:
-            data = json.loads(filepath.read_text()) if filepath.exists() else {}
+            data = json.loads(filepath.read_text(encoding)) if filepath.exists() else {}
         except Exception:
             log.warning(f'Could not load storage file {filepath}')
             data = {}
@@ -59,7 +60,7 @@ class PersistentDict(observables.ObservableDict):
             self.filepath.parent.mkdir(exist_ok=True)
 
         async def backup() -> None:
-            async with aiofiles.open(self.filepath, 'w') as f:
+            async with aiofiles.open(self.filepath, 'w', encoding=self.encoding) as f:
                 await f.write(json.dumps(self))
         if core.loop:
             background_tasks.create_lazy(backup(), name=self.filepath.stem)
@@ -93,7 +94,8 @@ class Storage:
 
     def __init__(self) -> None:
         self.path = Path(os.environ.get('NICEGUI_STORAGE_PATH', '.nicegui')).resolve()
-        self._general = PersistentDict(self.path / 'storage_general.json')
+        self.migrate_to_utf8()
+        self._general = PersistentDict(self.path / 'storage-general.json', encoding='utf-8')
         self._users: Dict[str, PersistentDict] = {}
 
     @property
@@ -132,7 +134,7 @@ class Storage:
             raise RuntimeError('app.storage.user needs a storage_secret passed in ui.run()')
         session_id = request.session['id']
         if session_id not in self._users:
-            self._users[session_id] = PersistentDict(self.path / f'storage_user_{session_id}.json')
+            self._users[session_id] = PersistentDict(self.path / f'storage-user-{session_id}.json', encoding='utf-8')
         return self._users[session_id]
 
     @property
@@ -144,5 +146,16 @@ class Storage:
         """Clears all storage."""
         self._general.clear()
         self._users.clear()
-        for filepath in self.path.glob('storage_*.json'):
+        for filepath in self.path.glob('storage-*.json'):
             filepath.unlink()
+
+    def migrate_to_utf8(self) -> None:
+        """Migrates storage files from system's default encoding to UTF-8.
+
+        To distinguish between the old and new encoding, the new files are named with dashes instead of underscores.
+        """
+        for filepath in self.path.glob('storage_*.json'):
+            new_filepath = filepath.with_stem(filepath.stem.replace('_', '-'))
+            data = json.loads(filepath.read_text())
+            filepath.rename(new_filepath)
+            new_filepath.write_text(json.dumps(data), encoding='utf-8')

+ 3 - 1
nicegui/ui.py

@@ -62,6 +62,7 @@ __all__ = [
     'select',
     'separator',
     'slider',
+    'space',
     'spinner',
     'splitter',
     'step',
@@ -160,7 +161,7 @@ from .elements.plotly import Plotly as plotly
 from .elements.progress import CircularProgress as circular_progress
 from .elements.progress import LinearProgress as linear_progress
 from .elements.pyplot import Pyplot as pyplot
-from .elements.query import query
+from .elements.query import Query as query
 from .elements.radio import Radio as radio
 from .elements.row import Row as row
 from .elements.scene import Scene as scene
@@ -168,6 +169,7 @@ 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
+from .elements.space import Space as space
 from .elements.spinner import Spinner as spinner
 from .elements.splitter import Splitter as splitter
 from .elements.stepper import Step as step

+ 17 - 3
tests/conftest.py

@@ -8,12 +8,15 @@ import icecream
 import pytest
 from selenium import webdriver
 from selenium.webdriver.chrome.service import Service
+from starlette.routing import Route
 
 from nicegui import Client, app, binding, core
 from nicegui.page import page
 
 from .screen import Screen
 
+# pylint: disable=redefined-outer-name
+
 DOWNLOAD_DIR = Path(__file__).parent / 'download'
 
 icecream.install()
@@ -21,10 +24,15 @@ icecream.install()
 
 @pytest.fixture
 def chrome_options(chrome_options: webdriver.ChromeOptions) -> webdriver.ChromeOptions:
+    """Configure the Chrome driver options."""
     chrome_options.add_argument('disable-dev-shm-using')
     chrome_options.add_argument('no-sandbox')
     chrome_options.add_argument('headless')
-    chrome_options.add_argument('disable-gpu')
+    # check if we are running on GitHub Actions
+    if 'GITHUB_ACTIONS' in os.environ:
+        chrome_options.add_argument('disable-gpu')
+    else:
+        chrome_options.add_argument('--use-gl=angle')
     chrome_options.add_argument('window-size=600x600')
     chrome_options.add_experimental_option('prefs', {
         "download.default_directory": str(DOWNLOAD_DIR),
@@ -38,14 +46,16 @@ def chrome_options(chrome_options: webdriver.ChromeOptions) -> webdriver.ChromeO
 
 @pytest.fixture
 def capabilities(capabilities: Dict) -> Dict:
+    """Configure the Chrome driver capabilities."""
     capabilities['goog:loggingPrefs'] = {'browser': 'ALL'}
     return capabilities
 
 
 @pytest.fixture(autouse=True)
 def reset_globals() -> Generator[None, None, None]:
+    """Reset the global state of the NiceGUI package."""
     for route in app.routes:
-        if route.path.startswith('/_nicegui/auto/static/'):
+        if isinstance(route, Route) and route.path.startswith('/_nicegui/auto/static/'):
             app.remove_route(route.path)
     for path in {'/'}.union(Client.page_routes.values()):
         app.remove_route(path)
@@ -54,7 +64,7 @@ def reset_globals() -> Generator[None, None, None]:
     app.user_middleware.clear()
     # NOTE favicon routes must be removed separately because they are not "pages"
     for route in app.routes:
-        if route.path.endswith('/favicon.ico'):
+        if isinstance(route, Route) and route.path.endswith('/favicon.ico'):
             app.routes.remove(route)
     importlib.reload(core)
     Client.instances.clear()
@@ -64,10 +74,12 @@ def reset_globals() -> Generator[None, None, None]:
     # NOTE we need to re-add the auto index route because we removed all routes above
     app.get('/')(Client.auto_index_client.build_response)
     binding.reset()
+    yield
 
 
 @pytest.fixture(scope='session', autouse=True)
 def remove_all_screenshots() -> None:
+    """Remove all screenshots from the screenshot directory before the test session."""
     if os.path.exists(Screen.SCREENSHOT_DIR):
         for name in os.listdir(Screen.SCREENSHOT_DIR):
             os.remove(os.path.join(Screen.SCREENSHOT_DIR, name))
@@ -75,6 +87,7 @@ def remove_all_screenshots() -> None:
 
 @pytest.fixture(scope='function')
 def driver(chrome_options: webdriver.ChromeOptions) -> webdriver.Chrome:
+    """Create a new Chrome driver instance."""
     s = Service()
     driver_ = webdriver.Chrome(service=s, options=chrome_options)
     driver_.implicitly_wait(Screen.IMPLICIT_WAIT)
@@ -86,6 +99,7 @@ def driver(chrome_options: webdriver.ChromeOptions) -> webdriver.Chrome:
 @pytest.fixture
 def screen(driver: webdriver.Chrome, request: pytest.FixtureRequest, caplog: pytest.LogCaptureFixture) \
         -> Generator[Screen, None, None]:
+    """Create a new Screen instance."""
     screen_ = Screen(driver, caplog)
     yield screen_
     if screen_.is_open:

+ 26 - 5
tests/screen.py

@@ -38,7 +38,8 @@ class Screen:
         self.server_thread.start()
 
     @property
-    def is_open(self) -> None:
+    def is_open(self) -> bool:
+        """Check if the browser is open."""
         # https://stackoverflow.com/a/66150779/3419103
         try:
             self.selenium.current_url  # pylint: disable=pointless-statement
@@ -74,31 +75,37 @@ class Screen:
                 if time.time() > deadline:
                     raise
                 time.sleep(0.1)
+                assert self.server_thread is not None
                 if not self.server_thread.is_alive():
                     raise RuntimeError('The NiceGUI server has stopped running') from e
 
     def close(self) -> None:
+        """Close the browser."""
         if self.is_open:
             self.selenium.close()
 
     def switch_to(self, tab_id: int) -> None:
+        """Switch to the tab with the given index, or create it if it does not exist."""
         window_count = len(self.selenium.window_handles)
         if tab_id > window_count:
             raise IndexError(f'Could not go to or create tab {tab_id}, there are only {window_count} tabs')
-        elif tab_id == window_count:
+        if tab_id == window_count:
             self.selenium.switch_to.new_window('tab')
         else:
             self.selenium.switch_to.window(self.selenium.window_handles[tab_id])
 
     def should_contain(self, text: str) -> None:
+        """Assert that the page contains the given text."""
         if self.selenium.title == text:
             return
         self.find(text)
 
     def wait_for(self, text: str) -> None:
+        """Wait until the page contains the given text."""
         self.should_contain(text)
 
     def should_not_contain(self, text: str, wait: float = 0.5) -> None:
+        """Assert that the page does not contain the given text."""
         assert self.selenium.title != text
         self.selenium.implicitly_wait(wait)
         with pytest.raises(AssertionError):
@@ -106,6 +113,7 @@ class Screen:
         self.selenium.implicitly_wait(self.IMPLICIT_WAIT)
 
     def should_contain_input(self, text: str) -> None:
+        """Assert that the page contains an input with the given value."""
         deadline = time.time() + self.IMPLICIT_WAIT
         while time.time() < deadline:
             for input_element in self.find_all_by_tag('input'):
@@ -115,6 +123,7 @@ class Screen:
         raise AssertionError(f'Could not find input with value "{text}"')
 
     def should_load_image(self, image: WebElement, *, timeout: float = 2.0) -> None:
+        """Assert that the given image has loaded."""
         deadline = time.time() + timeout
         while time.time() < deadline:
             js = 'return arguments[0].naturalWidth > 0 && arguments[0].naturalHeight > 0'
@@ -123,6 +132,7 @@ class Screen:
         raise AssertionError(f'Image not loaded: {image.get_attribute("outerHTML")}')
 
     def click(self, target_text: str) -> WebElement:
+        """Click on the element containing the given text."""
         element = self.find(target_text)
         try:
             element.click()
@@ -131,21 +141,25 @@ class Screen:
         return element
 
     def context_click(self, target_text: str) -> WebElement:
+        """Right-click on the element containing the given text."""
         element = self.find(target_text)
         action = ActionChains(self.selenium)
         action.context_click(element).perform()
         return element
 
     def click_at_position(self, element: WebElement, x: int, y: int) -> None:
+        """Click on the given element at the given position."""
         action = ActionChains(self.selenium)
         action.move_to_element_with_offset(element, x, y).click().perform()
 
     def type(self, text: str) -> None:
+        """Type the given text into the currently focused element."""
         self.selenium.execute_script("window.focus();")
         self.wait(0.2)
         self.selenium.switch_to.active_element.send_keys(text)
 
     def find(self, text: str) -> WebElement:
+        """Find the element containing the given text."""
         try:
             query = f'//*[not(self::script) and not(self::style) and text()[contains(., "{text}")]]'
             element = self.selenium.find_element(By.XPATH, query)
@@ -161,35 +175,41 @@ class Screen:
             raise AssertionError(f'Could not find "{text}"') from e
 
     def find_all(self, text: str) -> List[WebElement]:
+        """Find all elements containing the given text."""
         query = f'//*[not(self::script) and not(self::style) and text()[contains(., "{text}")]]'
         return self.selenium.find_elements(By.XPATH, query)
 
     def find_element(self, element: ui.element) -> WebElement:
+        """Find the given NiceGUI element."""
         return self.selenium.find_element(By.ID, f'c{element.id}')
 
     def find_by_class(self, name: str) -> WebElement:
+        """Find the element with the given CSS class."""
         return self.selenium.find_element(By.CLASS_NAME, name)
 
     def find_all_by_class(self, name: str) -> List[WebElement]:
+        """Find all elements with the given CSS class."""
         return self.selenium.find_elements(By.CLASS_NAME, name)
 
     def find_by_tag(self, name: str) -> WebElement:
+        """Find the element with the given HTML tag."""
         return self.selenium.find_element(By.TAG_NAME, name)
 
     def find_all_by_tag(self, name: str) -> List[WebElement]:
+        """Find all elements with the given HTML tag."""
         return self.selenium.find_elements(By.TAG_NAME, name)
 
     def render_js_logs(self) -> str:
+        """Render the browser console logs as a string."""
         console = '\n'.join(l['message'] for l in self.selenium.get_log('browser'))
         return f'-- console logs ---\n{console}\n---------------------'
 
-    def get_attributes(self, tag: str, attribute: str) -> List[str]:
-        return [t.get_attribute(attribute) for t in self.find_all_by_tag(tag)]
-
     def wait(self, t: float) -> None:
+        """Wait for the given number of seconds."""
         time.sleep(t)
 
     def shot(self, name: str) -> None:
+        """Take a screenshot and store it in the screenshots directory."""
         os.makedirs(self.SCREENSHOT_DIR, exist_ok=True)
         filename = f'{self.SCREENSHOT_DIR}/{name}.png'
         print(f'Storing screenshot to {filename}')
@@ -212,6 +232,7 @@ class Screen:
 
     @contextmanager
     def implicitly_wait(self, t: float) -> None:
+        """Temporarily change the implicit wait time."""
         self.selenium.implicitly_wait(t)
         yield
         self.selenium.implicitly_wait(self.IMPLICIT_WAIT)

+ 5 - 5
tests/test_aggrid.py

@@ -90,13 +90,13 @@ def test_dynamic_method(screen: Screen):
     assert 48 <= heights[2] <= 50
 
 
-def test_call_api_method_with_argument(screen: Screen):
+def test_run_grid_method_with_argument(screen: Screen):
     grid = ui.aggrid({
         'columnDefs': [{'field': 'name', 'filter': True}],
         'rowData': [{'name': 'Alice'}, {'name': 'Bob'}, {'name': 'Carol'}],
     })
     filter_model = {'name': {'filterType': 'text', 'type': 'equals', 'filter': 'Alice'}}
-    ui.button('Filter', on_click=lambda: grid.call_api_method('setFilterModel', filter_model))
+    ui.button('Filter', on_click=lambda: grid.run_grid_method('setFilterModel', filter_model))
 
     screen.open('/')
     screen.should_contain('Alice')
@@ -108,12 +108,12 @@ def test_call_api_method_with_argument(screen: Screen):
     screen.should_not_contain('Carol')
 
 
-def test_call_column_api_method_with_argument(screen: Screen):
+def test_run_column_method_with_argument(screen: Screen):
     grid = ui.aggrid({
         'columnDefs': [{'field': 'name'}, {'field': 'age', 'hide': True}],
         'rowData': [{'name': 'Alice', 'age': '18'}, {'name': 'Bob', 'age': '21'}, {'name': 'Carol', 'age': '42'}],
     })
-    ui.button('Show Age', on_click=lambda: grid.call_column_api_method('setColumnVisible', 'age', True))
+    ui.button('Show Age', on_click=lambda: grid.run_column_method('setColumnVisible', 'age', True))
 
     screen.open('/')
     screen.should_contain('Alice')
@@ -188,7 +188,7 @@ def test_create_dynamically(screen: Screen):
 
 def test_api_method_after_creation(screen: Screen):
     options = {'columnDefs': [{'field': 'name'}], 'rowData': [{'name': 'Alice'}]}
-    ui.button('Create', on_click=lambda: ui.aggrid(options).call_api_method('selectAll'))
+    ui.button('Create', on_click=lambda: ui.aggrid(options).run_grid_method('selectAll'))
 
     screen.open('/')
     screen.click('Create')

+ 16 - 0
tests/test_echart.py

@@ -61,3 +61,19 @@ def test_nested_expansion(screen: Screen):
     canvas = screen.find_by_tag('canvas')
     assert canvas.rect['height'] == 168
     assert canvas.rect['width'] == 568
+
+
+def test_run_method(screen: Screen):
+    echart = ui.echart({
+        'xAxis': {'type': 'value'},
+        'yAxis': {'type': 'category', 'data': ['A', 'B', 'C']},
+        'series': [{'type': 'line', 'data': [0.1, 0.2, 0.3]}],
+    }).classes('w-[600px]')
+
+    async def get_width():
+        ui.label(f'Width: {await echart.run_chart_method("getWidth")}px')
+    ui.button('Get Width', on_click=get_width)
+
+    screen.open('/')
+    screen.click('Get Width')
+    screen.should_contain('Width: 600px')

+ 2 - 0
tests/test_input.py

@@ -64,11 +64,13 @@ def test_input_validation(screen: Screen):
     element.send_keys('John')
     screen.should_contain('Too short')
     assert input_.error == 'Too short'
+    assert not input_.validate()
 
     element.send_keys(' Doe')
     screen.wait(0.5)
     screen.should_not_contain('Too short')
     assert input_.error is None
+    assert input_.validate()
 
 
 def test_input_with_multi_word_error_message(screen: Screen):

+ 20 - 0
tests/test_json_editor.py

@@ -0,0 +1,20 @@
+from nicegui import ui
+
+from .screen import Screen
+
+
+def test_json_editor_methods(screen: Screen):
+    editor = ui.json_editor({'content': {'json': {'a': 1, 'b': 2}}})
+
+    async def get_data():
+        data = await editor.run_editor_method('get')
+        ui.label(f'Data: {data}')
+    ui.button('Get Data', on_click=get_data)
+
+    screen.open('/')
+    screen.should_contain('text')
+    screen.should_contain('tree')
+    screen.should_contain('table')
+
+    screen.click('Get Data')
+    screen.should_contain("Data: {'json': {'a': 1, 'b': 2}}")

+ 1 - 1
tests/test_scene.py

@@ -14,7 +14,7 @@ def test_moving_sphere_with_timer(screen: Screen):
 
     screen.open('/')
 
-    def position() -> None:
+    def position() -> float:
         for _ in range(3):
             try:
                 pos = screen.selenium.execute_script(f'return scene_c{scene.id}.getObjectByName("sphere").position.z')

+ 5 - 8
tests/test_storage.py

@@ -1,5 +1,4 @@
 import asyncio
-import warnings
 from pathlib import Path
 
 import httpx
@@ -91,7 +90,7 @@ async def test_access_user_storage_from_fastapi(screen: Screen):
         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"}'
+        assert next(Path('.nicegui').glob('storage-user-*.json')).read_text('utf-8') == '{"msg":"yes"}'
 
 
 def test_access_user_storage_on_interaction(screen: Screen):
@@ -105,7 +104,7 @@ def test_access_user_storage_on_interaction(screen: Screen):
     screen.open('/')
     screen.click('switch')
     screen.wait(0.5)
-    assert next(Path('.nicegui').glob('storage_user_*.json')).read_text() == '{"test_switch":true}'
+    assert next(Path('.nicegui').glob('storage-user-*.json')).read_text('utf-8') == '{"test_switch":true}'
 
 
 def test_access_user_storage_from_button_click_handler(screen: Screen):
@@ -117,7 +116,7 @@ def test_access_user_storage_from_button_click_handler(screen: Screen):
     screen.open('/')
     screen.click('test')
     screen.wait(1)
-    assert next(Path('.nicegui').glob('storage_user_*.json')).read_text() == '{"inner_function":"works"}'
+    assert next(Path('.nicegui').glob('storage-user-*.json')).read_text('utf-8') == '{"inner_function":"works"}'
 
 
 async def test_access_user_storage_from_background_task(screen: Screen):
@@ -130,7 +129,7 @@ async def test_access_user_storage_from_background_task(screen: Screen):
 
     screen.ui_run_kwargs['storage_secret'] = 'just a test'
     screen.open('/')
-    assert next(Path('.nicegui').glob('storage_user_*.json')).read_text() == '{"subtask":"works"}'
+    assert next(Path('.nicegui').glob('storage-user-*.json')).read_text('utf-8') == '{"subtask":"works"}'
 
 
 def test_user_and_general_storage_is_persisted(screen: Screen):
@@ -155,8 +154,6 @@ def test_user_and_general_storage_is_persisted(screen: Screen):
 
 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),
@@ -166,4 +163,4 @@ def test_rapid_storage(screen: Screen):
     screen.open('/')
     screen.click('test')
     screen.wait(0.5)
-    assert Path('.nicegui', 'storage_general.json').read_text() == '{"one":1,"two":2,"three":3}'
+    assert Path('.nicegui', 'storage-general.json').read_text('utf-8') == '{"one":1,"two":2,"three":3}'

+ 3 - 2
tests/test_timer.py

@@ -1,5 +1,4 @@
 import asyncio
-import warnings
 
 import pytest
 
@@ -80,15 +79,17 @@ def test_setting_visibility(screen: Screen, once: bool):
 
 
 def test_awaiting_coroutine(screen: Screen):
-    warnings.simplefilter('error')
+    user = {'name': 'John Doe'}
 
     async def update_user():
         await asyncio.sleep(0.1)
+        user['name'] = 'Jane Doe'
 
     ui.timer(1, update_user)
 
     screen.open('/')
     screen.wait(1)
+    assert user['name'] == 'Jane Doe'
 
 
 def test_timer_on_deleted_container(screen: Screen):

+ 2 - 2
website/documentation/content/aggrid_documentation.py

@@ -25,8 +25,8 @@ def main_demo() -> None:
         grid.update()
 
     ui.button('Update', on_click=update)
-    ui.button('Select all', on_click=lambda: grid.call_api_method('selectAll'))
-    ui.button('Show parent', on_click=lambda: grid.call_column_api_method('setColumnVisible', 'parent', True))
+    ui.button('Select all', on_click=lambda: grid.run_grid_method('selectAll'))
+    ui.button('Show parent', on_click=lambda: grid.run_column_method('setColumnVisible', 'parent', True))
 
 
 @doc.demo('Select AG Grid Rows', '''

+ 28 - 0
website/documentation/content/echart_documentation.py

@@ -47,4 +47,32 @@ def dynamic_properties() -> None:
     })
 
 
+@doc.demo('Run methods', '''
+    You can run methods of the EChart instance using the `run_chart_method` method.
+    This demo shows how to show and hide the loading animation, how to get the current width of the chart,
+    and how to add tooltips with a custom formatter.
+
+    The colon ":" in front of the method name "setOption" indicates that the argument is a JavaScript expression
+    that is evaluated on the client before it is passed to the method.
+''')
+def methods_demo() -> None:
+    echart = ui.echart({
+        'xAxis': {'type': 'category', 'data': ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']},
+        'yAxis': {'type': 'value'},
+        'series': [{'type': 'line', 'data': [150, 230, 224, 218, 135]}],
+    })
+
+    ui.button('Show Loading', on_click=lambda: echart.run_chart_method('showLoading'))
+    ui.button('Hide Loading', on_click=lambda: echart.run_chart_method('hideLoading'))
+
+    async def get_width():
+        width = await echart.run_chart_method('getWidth')
+        ui.notify(f'Width: {width}')
+    ui.button('Get Width', on_click=get_width)
+
+    ui.button('Set Tooltip', on_click=lambda: echart.run_chart_method(
+        ':setOption', r'{tooltip: {formatter: params => "$" + params.value}}',
+    ))
+
+
 doc.reference(ui.echart)

+ 18 - 0
website/documentation/content/grid_documentation.py

@@ -16,4 +16,22 @@ def main_demo() -> None:
         ui.label('1.80m')
 
 
+@doc.demo('Cells spanning multiple columns', '''
+    This demo shows how to span cells over multiple columns.
+
+    Note that there is [no Tailwind class for spanning 15 columns](https://tailwindcss.com/docs/grid-column),
+    but we can set [arbitrary values](https://tailwindcss.com/docs/grid-column#arbitrary-values) using square brackets.
+    Alternatively you could use the corresponding CSS definition: `.style('grid-column: span 15 / span 15')`.
+''')
+def span_demo() -> None:
+    with ui.grid(columns=16).classes('w-full gap-0'):
+        ui.label('full').classes('col-span-full border p-1')
+        ui.label('8').classes('col-span-8 border p-1')
+        ui.label('8').classes('col-span-8 border p-1')
+        ui.label('12').classes('col-span-12 border p-1')
+        ui.label('4').classes('col-span-4 border p-1')
+        ui.label('15').classes('col-[span_15] border p-1')
+        ui.label('1').classes('col-span-1 border p-1')
+
+
 doc.reference(ui.grid)

+ 15 - 2
website/documentation/content/interactive_image_documentation.py

@@ -5,9 +5,9 @@ from . import doc
 
 @doc.demo(ui.interactive_image)
 def main_demo() -> None:
-    from nicegui.events import MouseEventArguments
+    from nicegui import events
 
-    def mouse_handler(e: MouseEventArguments):
+    def mouse_handler(e: events.MouseEventArguments):
         color = 'SkyBlue' if e.type == 'mousedown' else 'SteelBlue'
         ii.content += f'<circle cx="{e.image_x}" cy="{e.image_y}" r="15" fill="none" stroke="{color}" stroke-width="4" />'
         ui.notify(f'{e.type} at ({e.image_x:.1f}, {e.image_y:.1f})')
@@ -38,4 +38,17 @@ def force_reload():
     ui.button('Force reload', on_click=img.force_reload)
 
 
+@doc.demo('Blank canvas', '''
+    You can also create a blank canvas with a given size.
+    This is useful if you want to draw something without loading a background image.
+''')
+def blank_canvas():
+    ui.interactive_image(
+        size=(800, 600), cross=True,
+        on_mouse=lambda e: e.sender.set_content(f'''
+            <circle cx="{e.image_x}" cy="{e.image_y}" r="50" fill="orange" />
+        '''),
+    ).classes('w-64 bg-blue-50')
+
+
 doc.reference(ui.interactive_image)

+ 28 - 0
website/documentation/content/json_editor_documentation.py

@@ -23,4 +23,32 @@ def main_demo() -> None:
                    on_change=lambda e: ui.notify(f'Change: {e}'))
 
 
+@doc.demo('Run methods', '''
+    You can run methods of the JSONEditor instance using the `run_editor_method` method.
+    This demo shows how to expand and collapse all nodes and how to get the current data.
+
+    The colon ":" in front of the method name "expand" indicates that the value "path => true" is a JavaScript expression
+    that is evaluated on the client before it is passed to the method.
+''')
+def methods_demo() -> None:
+    json = {
+        'Name': 'Alice',
+        'Age': 42,
+        'Address': {
+            'Street': 'Main Street',
+            'City': 'Wonderland',
+        },
+    }
+    editor = ui.json_editor({'content': {'json': json}})
+
+    ui.button('Expand', on_click=lambda: editor.run_editor_method(':expand', 'path => true'))
+    ui.button('Collapse', on_click=lambda: editor.run_editor_method(':expand', 'path => false'))
+    ui.button('Readonly', on_click=lambda: editor.run_editor_method('updateProps', {'readOnly': True}))
+
+    async def get_data() -> None:
+        data = await editor.run_editor_method('get')
+        ui.notify(data)
+    ui.button('Get Data', on_click=get_data)
+
+
 doc.reference(ui.json_editor)

+ 32 - 0
website/documentation/content/plotly_documentation.py

@@ -67,4 +67,36 @@ def plot_updates():
     ui.button('Add trace', on_click=add_trace)
 
 
+@doc.demo('Plot events', '''
+    This demo shows how to handle Plotly events.
+    Try clicking on a data point to see the event data.
+
+    Currently, the following events are supported:
+    "plotly\_click",
+    "plotly\_legendclick",
+    "plotly\_selecting",
+    "plotly\_selected",
+    "plotly\_hover",
+    "plotly\_unhover",
+    "plotly\_legenddoubleclick",
+    "plotly\_restyle",
+    "plotly\_relayout",
+    "plotly\_webglcontextlost",
+    "plotly\_afterplot",
+    "plotly\_autosize",
+    "plotly\_deselect",
+    "plotly\_doubleclick",
+    "plotly\_redraw",
+    "plotly\_animated".
+    For more information, see the [Plotly documentation](https://plotly.com/javascript/plotlyjs-events/).
+''')
+def plot_events():
+    import plotly.graph_objects as go
+
+    fig = go.Figure(go.Scatter(x=[1, 2, 3, 4], y=[1, 2, 3, 2.5]))
+    fig.update_layout(margin=dict(l=0, r=0, t=0, b=0))
+    plot = ui.plotly(fig).classes('w-full h-40')
+    plot.on('plotly_click', ui.notify)
+
+
 doc.reference(ui.plotly)

+ 3 - 0
website/documentation/content/query_documentation.py

@@ -36,3 +36,6 @@ def remove_padding():
     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')
+
+
+doc.reference(ui.query)

+ 3 - 2
website/documentation/content/section_page_layout.py

@@ -3,8 +3,8 @@ from nicegui import ui
 from . import (card_documentation, carousel_documentation, column_documentation, context_menu_documentation,
                dialog_documentation, doc, expansion_documentation, grid_documentation, menu_documentation,
                notification_documentation, notify_documentation, pagination_documentation, row_documentation,
-               scroll_area_documentation, separator_documentation, splitter_documentation, stepper_documentation,
-               tabs_documentation, timeline_documentation, tooltip_documentation)
+               scroll_area_documentation, separator_documentation, space_documentation, splitter_documentation,
+               stepper_documentation, tabs_documentation, timeline_documentation, tooltip_documentation)
 
 doc.title('Page *Layout*')
 
@@ -63,6 +63,7 @@ def clear_containers_demo():
 doc.intro(expansion_documentation)
 doc.intro(scroll_area_documentation)
 doc.intro(separator_documentation)
+doc.intro(space_documentation)
 doc.intro(splitter_documentation)
 doc.intro(tabs_documentation)
 doc.intro(stepper_documentation)

+ 24 - 0
website/documentation/content/space_documentation.py

@@ -0,0 +1,24 @@
+from nicegui import ui
+
+from . import doc
+
+
+@doc.demo(ui.space)
+def main_demo() -> None:
+    with ui.row().classes('w-full border'):
+        ui.label('Left')
+        ui.space()
+        ui.label('Right')
+
+
+@doc.demo('Vertical space', '''
+    This element can also be used to fill vertical space.
+''')
+def vertical_demo() -> None:
+    with ui.column().classes('h-32 border'):
+        ui.label('Top')
+        ui.space()
+        ui.label('Bottom')
+
+
+doc.reference(ui.space)

+ 25 - 0
website/documentation/content/tabs_documentation.py

@@ -57,6 +57,31 @@ def switch_tabs():
     ui.button('GoTo 2', on_click=lambda: tabs.set_value('Tab 2'))
 
 
+@doc.demo('Vertical tabs with splitter', '''
+    Like in [Quasar's vertical tabs example](https://quasar.dev/vue-components/tabs#vertical),
+    we can combine `ui.splitter` and tab elements to create a vertical tabs layout.
+''')
+def vertical_tabs():
+    with ui.splitter(value=30).classes('w-full h-56') as splitter:
+        with splitter.before:
+            with ui.tabs().props('vertical').classes('w-full') as tabs:
+                mail = ui.tab('Mails', icon='mail')
+                alarm = ui.tab('Alarms', icon='alarm')
+                movie = ui.tab('Movies', icon='movie')
+        with splitter.after:
+            with ui.tab_panels(tabs, value=mail) \
+                    .props('vertical').classes('w-full h-full'):
+                with ui.tab_panel(mail):
+                    ui.label('Mails').classes('text-h4')
+                    ui.label('Content of mails')
+                with ui.tab_panel(alarm):
+                    ui.label('Alarms').classes('text-h4')
+                    ui.label('Content of alarms')
+                with ui.tab_panel(movie):
+                    ui.label('Movies').classes('text-h4')
+                    ui.label('Content of movies')
+
+
 doc.reference(ui.tabs, title='Reference for ui.tabs')
 doc.reference(ui.tabs, title='Reference for ui.tab')
 doc.reference(ui.tabs, title='Reference for ui.tab_panels')