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
   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.4.6
-date-released: '2023-12-18'
+version: v1.4.7
+date-released: '2023-12-26'
 url: https://github.com/zauberzeug/nicegui
 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') 
 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
 #!/usr/bin/env python3
 from io import BytesIO
 from io import BytesIO
-from pathlib import Path
 
 
 import cairo
 import cairo
 
 
 from nicegui import ui
 from nicegui import ui
 
 
-PDF_PATH = Path('output.pdf')
-
 
 
 def generate_svg() -> str:
 def generate_svg() -> str:
     output = BytesIO()
     output = BytesIO()
@@ -25,7 +22,7 @@ def generate_pdf() -> bytes:
     return output.getvalue()
     return output.getvalue()
 
 
 
 
-def draw(surface: cairo.SVGSurface) -> None:
+def draw(surface: cairo.Surface) -> None:
     context = cairo.Context(surface)
     context = cairo.Context(surface)
     context.select_font_face('Arial', cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
     context.select_font_face('Arial', cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
     context.set_font_size(20)
     context.set_font_size(20)
@@ -37,7 +34,6 @@ def draw(surface: cairo.SVGSurface) -> None:
 
 
 def update() -> None:
 def update() -> None:
     preview.content = generate_svg()
     preview.content = generate_svg()
-    PDF_PATH.write_bytes(generate_pdf())
 
 
 
 
 with ui.row():
 with ui.row():
@@ -46,6 +42,6 @@ with ui.row():
         email = ui.input('E-Mail', placeholder='Enter your E-Mail address', on_change=update)
         email = ui.input('E-Mail', placeholder='Enter your E-Mail address', on_change=update)
     preview = ui.html().classes('border-2 border-gray-500')
     preview = ui.html().classes('border-2 border-gray-500')
     update()
     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()
 ui.run()

+ 1 - 1
nicegui/client.py

@@ -252,7 +252,7 @@ class Client:
         """Forward an event to the corresponding element."""
         """Forward an event to the corresponding element."""
         with self:
         with self:
             sender = self.elements.get(msg['id'])
             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', [])]
                 msg['args'] = [None if arg is None else json.loads(arg) for arg in msg.get('args', [])]
                 if len(msg['args']) == 1:
                 if len(msg['args']) == 1:
                     msg['args'] = msg['args'][0]
                     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.grid = new agGrid.Grid(this.$el, this.gridOptions);
       this.gridOptions.api.addGlobalListener(this.handle_event);
       this.gridOptions.api.addGlobalListener(this.handle_event);
     },
     },
-    call_api_method(name, ...args) {
+    run_grid_method(name, ...args) {
       return this.gridOptions.api[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);
       return this.gridOptions.columnApi[name](...args);
     },
     },
     handle_event(type, 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/>`_.
         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 options: dictionary of AG Grid options
         :param html_columns: list of columns that should be rendered as HTML (default: `[]`)
         :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
     @classmethod
     def from_pandas(cls,
     def from_pandas(cls,
-                    df: pd.DataFrame, *,
+                    df: 'pd.DataFrame', *,
                     theme: str = 'balham',
                     theme: str = 'balham',
                     auto_size_columns: bool = True,
                     auto_size_columns: bool = True,
                     options: Dict = {}) -> Self:
                     options: Dict = {}) -> Self:
@@ -87,7 +87,11 @@ class AgGrid(Element, component='aggrid.js', libraries=['lib/aggrid/ag-grid-comm
         self.run_method('update_grid')
         self.run_method('update_grid')
 
 
     def call_api_method(self, name: str, *args, timeout: float = 1, check_interval: float = 0.01) -> AwaitableResponse:
     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.
         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: 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.
         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: 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]:
     async def get_selected_rows(self) -> List[Dict]:
         """Get the currently selected rows.
         """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
         :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)
         return cast(List[Dict], result)
 
 
     async def get_selected_row(self) -> Optional[Dict]:
     async def get_selected_row(self) -> Optional[Dict]:

+ 2 - 2
nicegui/elements/audio.py

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

+ 1 - 3
nicegui/elements/card.py

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

+ 7 - 0
nicegui/elements/echart.js

@@ -19,6 +19,13 @@ export default {
       convertDynamicProperties(this.options, true);
       convertDynamicProperties(this.options, true);
       this.chart.setOption(this.options, { notMerge: this.chart.options?.series.length != this.options.series.length });
       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: {
   props: {
     options: Object,
     options: Object,

+ 19 - 0
nicegui/elements/echart.py

@@ -1,5 +1,6 @@
 from typing import Callable, Dict, Optional
 from typing import Callable, Dict, Optional
 
 
+from ..awaitable_response import AwaitableResponse
 from ..element import Element
 from ..element import Element
 from ..events import EChartPointClickEventArguments, GenericEventArguments, handle_event
 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:
     def update(self) -> None:
         super().update()
         super().update()
         self.run_method('update_chart')
         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 typing import Any, Callable, Optional
 
 
 from .mixins.disableable_element import DisableableElement
 from .mixins.disableable_element import DisableableElement
+from .mixins.text_element import TextElement
 from .mixins.value_element import ValueElement
 from .mixins.value_element import ValueElement
 
 
 
 
-class Expansion(ValueElement, DisableableElement):
+class Expansion(TextElement, ValueElement, DisableableElement):
 
 
     def __init__(self,
     def __init__(self,
-                 text: Optional[str] = None, *,
+                 text: str = '', *,
                  icon: Optional[str] = None,
                  icon: Optional[str] = None,
                  value: bool = False,
                  value: bool = False,
                  on_value_change: Optional[Callable[..., Any]] = None
                  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 value: whether the expansion should be opened on creation (default: `False`)
         :param on_value_change: callback to execute when value changes
         :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._props['icon'] = icon
         self._classes.append('nicegui-expansion')
         self._classes.append('nicegui-expansion')
 
 
@@ -34,3 +33,6 @@ class Expansion(ValueElement, DisableableElement):
     def close(self) -> None:
     def close(self) -> None:
         """Close the expansion."""
         """Close the expansion."""
         self.value = False
         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,
                  password_toggle_button: bool = False,
                  on_change: Optional[Callable[..., Any]] = None,
                  on_change: Optional[Callable[..., Any]] = None,
                  autocomplete: Optional[List[str]] = None,
                  autocomplete: Optional[List[str]] = None,
-                 validation: Dict[str, Callable[..., bool]] = {}) -> None:
+                 validation: Optional[Dict[str, Callable[..., bool]]] = None) -> None:
         """Text Input
         """Text Input
 
 
         This element is based on Quasar's `QInput <https://quasar.dev/vue-components/input>`_ component.
         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 {
 export default {
   template: `
   template: `
-    <div style="position:relative">
+    <div :style="{ position: 'relative', aspectRatio: size ? size[0] / size[1] : undefined }">
       <img
       <img
         ref="img"
         ref="img"
         :src="computed_src"
         :src="computed_src"
-        style="width:100%; height:100%;"
+        :style="{ width: '100%', height: '100%', opacity: src ? 1 : 0 }"
         @load="onImageLoaded"
         @load="onImageLoaded"
         v-on="onCrossEvents"
         v-on="onCrossEvents"
         v-on="onUserEvents"
         v-on="onUserEvents"
         draggable="false"
         draggable="false"
       />
       />
       <svg style="position:absolute;top:0;left:0;pointer-events:none" :viewBox="viewBox">
       <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="x" y1="0" :x2="x" y2="100%" stroke="black" />
           <line x1="0" :y1="y" x2="100%" :y2="y" stroke="black" />
           <line x1="0" :y1="y" x2="100%" :y2="y" stroke="black" />
         </g>
         </g>
@@ -23,9 +23,11 @@ export default {
   data() {
   data() {
     return {
     return {
       viewBox: "0 0 0 0",
       viewBox: "0 0 0 0",
+      loaded_image_width: 0,
+      loaded_image_height: 0,
       x: 100,
       x: 100,
       y: 100,
       y: 100,
-      cssDisplay: "none",
+      showCross: false,
       computed_src: undefined,
       computed_src: undefined,
       waiting_source: undefined,
       waiting_source: undefined,
       loading: false,
       loading: false,
@@ -60,19 +62,28 @@ export default {
         this.computed_src = new_src;
         this.computed_src = new_src;
         this.loading = true;
         this.loading = true;
       }
       }
+      if (!this.src && this.size) {
+        this.viewBox = `0 0 ${this.size[0]} ${this.size[1]}`;
+      }
     },
     },
     updateCrossHair(e) {
     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) {
     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) {
     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", {
       this.$emit("mouse", {
         mouse_event_type: type,
         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,
         button: e.button,
         buttons: e.buttons,
         buttons: e.buttons,
         altKey: e.altKey,
         altKey: e.altKey,
@@ -86,8 +97,8 @@ export default {
     onCrossEvents() {
     onCrossEvents() {
       if (!this.cross) return {};
       if (!this.cross) return {};
       return {
       return {
-        mouseenter: () => (this.cssDisplay = "block"),
-        mouseleave: () => (this.cssDisplay = "none"),
+        mouseenter: () => (this.showCross = true),
+        mouseleave: () => (this.showCross = false),
         mousemove: (event) => this.updateCrossHair(event),
         mousemove: (event) => this.updateCrossHair(event),
       };
       };
     },
     },
@@ -102,6 +113,7 @@ export default {
   props: {
   props: {
     src: String,
     src: String,
     content: String,
     content: String,
+    size: Object,
     events: Array,
     events: Array,
     cross: Boolean,
     cross: Boolean,
     t: String,
     t: String,

+ 16 - 3
nicegui/elements/interactive_image.py

@@ -2,7 +2,7 @@ from __future__ import annotations
 
 
 import time
 import time
 from pathlib import Path
 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 .. import optional_features
 from ..events import GenericEventArguments, MouseEventArguments, handle_event
 from ..events import GenericEventArguments, MouseEventArguments, handle_event
@@ -24,6 +24,7 @@ class InteractiveImage(SourceElement, ContentElement, component='interactive_ima
     def __init__(self,
     def __init__(self,
                  source: Union[str, Path, 'PIL_Image'] = '', *,
                  source: Union[str, Path, 'PIL_Image'] = '', *,
                  content: str = '',
                  content: str = '',
+                 size: Optional[Tuple[int, int]] = None,
                  on_mouse: Optional[Callable[..., Any]] = None,
                  on_mouse: Optional[Callable[..., Any]] = None,
                  events: List[str] = ['click'],
                  events: List[str] = ['click'],
                  cross: bool = False,
                  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.
         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.
         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 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 events: list of JavaScript events to subscribe to (default: `['click']`)
         :param cross: whether to show crosshairs (default: `False`)
         :param cross: whether to show crosshairs (default: `False`)
         """
         """
         super().__init__(source=source, content=content)
         super().__init__(source=source, content=content)
         self._props['events'] = events
         self._props['events'] = events
         self._props['cross'] = cross
         self._props['cross'] = cross
+        self._props['size'] = size
 
 
         def handle_mouse(e: GenericEventArguments) -> None:
         def handle_mouse(e: GenericEventArguments) -> None:
             if on_mouse is None:
             if on_mouse is None:

+ 9 - 0
nicegui/elements/json_editor.js

@@ -31,6 +31,15 @@ export default {
         this.editor.destroy();
         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: {
   props: {
     properties: Object,
     properties: Object,

+ 19 - 0
nicegui/elements/json_editor.py

@@ -1,5 +1,6 @@
 from typing import Callable, Dict, Optional
 from typing import Callable, Dict, Optional
 
 
+from ..awaitable_response import AwaitableResponse
 from ..element import Element
 from ..element import Element
 from ..events import GenericEventArguments, JsonEditorChangeEventArguments, JsonEditorSelectEventArguments, handle_event
 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:
     def update(self) -> None:
         super().update()
         super().update()
         self.run_method('update_editor')
         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):
 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)
         super().__init__(**kwargs)
-        self.validation = validation
+        self.validation = validation if validation is not None else {}
         self._error: Optional[str] = None
         self._error: Optional[str] = None
 
 
     @property
     @property
@@ -15,16 +15,17 @@ class ValidationElement(ValueElement):
         """The latest error message from the validation functions."""
         """The latest error message from the validation functions."""
         return self._error
         return self._error
 
 
-    def validate(self) -> None:
+    def validate(self) -> bool:
         """Validate the current value and set the error message if necessary."""
         """Validate the current value and set the error message if necessary."""
         for message, check in self.validation.items():
         for message, check in self.validation.items():
             if not check(self.value):
             if not check(self.value):
                 self._error = message
                 self._error = message
                 self.props(f'error error-message="{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:
     def _handle_value_change(self, value: Any) -> None:
         super()._handle_value_change(value)
         super()._handle_value_change(value)

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

@@ -20,10 +20,10 @@ class ValueElement(Element):
                  **kwargs: Any,
                  **kwargs: Any,
                  ) -> None:
                  ) -> None:
         super().__init__(**kwargs)
         super().__init__(**kwargs)
+        self._send_update_on_value_change = True
         self.set_value(value)
         self.set_value(value)
         self._props[self.VALUE_PROP] = self._value_to_model_value(value)
         self._props[self.VALUE_PROP] = self._value_to_model_value(value)
         self._props['loopback'] = self.LOOPBACK
         self._props['loopback'] = self.LOOPBACK
-        self._send_update_on_value_change = True
         self._change_handler = on_value_change
         self._change_handler = on_value_change
 
 
         def handle_change(e: GenericEventArguments) -> None:
         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 type: optional type ("positive", "negative", "warning", "info" or "ongoing")
         :param color: optional color name
         :param color: optional color name
         :param multi_line: enable multi-line notifications
         :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)
         :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>`_.
         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,
                  suffix: Optional[str] = None,
                  format: Optional[str] = None,  # pylint: disable=redefined-builtin
                  format: Optional[str] = None,  # pylint: disable=redefined-builtin
                  on_change: Optional[Callable[..., Any]] = None,
                  on_change: Optional[Callable[..., Any]] = None,
-                 validation: Dict[str, Callable[..., bool]] = {},
+                 validation: Optional[Dict[str, Callable[..., bool]]] = None,
                  ) -> None:
                  ) -> None:
         """Number Input
         """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);
         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
       // store last options
       this.last_options = options;
       this.last_options = options;
     },
     },

+ 58 - 12
nicegui/elements/query.py

@@ -6,7 +6,7 @@ from .. import context
 from ..element import Element
 from ..element import Element
 
 
 
 
-class Query(Element, component='query.js'):
+class QueryElement(Element, component='query.js'):
 
 
     def __init__(self, selector: str) -> None:
     def __init__(self, selector: str) -> None:
         super().__init__()
         super().__init__()
@@ -53,16 +53,62 @@ class Query(Element, component='query.js'):
         return self
         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.texture_loader = new THREE.TextureLoader();
     this.stl_loader = new STLLoader();
     this.stl_loader = new STLLoader();
 
 
-    const connectInterval = setInterval(async () => {
+    const connectInterval = setInterval(() => {
       if (window.socket.id === undefined) return;
       if (window.socket.id === undefined) return;
       this.$emit("init", { socket_id: window.socket.id });
       this.$emit("init", { socket_id: window.socket.id });
       clearInterval(connectInterval);
       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
     @classmethod
     def from_pandas(cls,
     def from_pandas(cls,
-                    df: pd.DataFrame,
+                    df: 'pd.DataFrame',
                     row_key: str = 'id',
                     row_key: str = 'id',
                     title: Optional[str] = None,
                     title: Optional[str] = None,
                     selection: Optional[Literal['single', 'multiple']] = 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,
                  placeholder: Optional[str] = None,
                  value: str = '',
                  value: str = '',
                  on_change: Optional[Callable[..., Any]] = None,
                  on_change: Optional[Callable[..., Any]] = None,
-                 validation: Dict[str, Callable[..., bool]] = {},
+                 validation: Optional[Dict[str, Callable[..., bool]]] = None,
                  ) -> None:
                  ) -> None:
         """Textarea
         """Textarea
 
 

+ 2 - 2
nicegui/elements/video.py

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

+ 1 - 1
nicegui/events.py

@@ -181,7 +181,7 @@ class KeyboardKey:
     @property
     @property
     def enter(self) -> bool:
     def enter(self) -> bool:
         """Whether the key is the enter key."""
         """Whether the key is the enter key."""
-        return self.name == 'enter'
+        return self.name == 'Enter'
 
 
     @property
     @property
     def shift(self) -> bool:
     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 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`)
         :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:
         with context.get_client().layout:
             super().__init__(tag='q-header', value=value, on_value_change=None)
             super().__init__(tag='q-header', value=value, on_value_change=None)
         self._classes.append('nicegui-header')
         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 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`)
         :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:
         with context.get_client().layout:
             super().__init__('q-drawer')
             super().__init__('q-drawer')
         if value is None:
         if value is None:
@@ -227,7 +227,7 @@ class Footer(ValueElement):
         :param elevated: whether the footer should have a shadow (default: `False`)
         :param elevated: whether the footer should have a shadow (default: `False`)
         :param wrap: whether the footer should wrap its content (default: `True`)
         :param wrap: whether the footer should wrap its content (default: `True`)
         """
         """
-        _check_current_slot()
+        _check_current_slot(self)
         with context.get_client().layout:
         with context.get_client().layout:
             super().__init__(tag='q-footer', value=value, on_value_change=None)
             super().__init__(tag='q-footer', value=value, on_value_change=None)
         self.classes('nicegui-footer')
         self.classes('nicegui-footer')
@@ -270,8 +270,9 @@ class PageSticky(Element):
         self._props['offset'] = [x_offset, y_offset]
         self._props['offset'] = [x_offset, y_offset]
 
 
 
 
-def _check_current_slot() -> None:
+def _check_current_slot(element: Element) -> None:
     parent = context.get_slot().parent
     parent = context.get_slot().parent
     if parent != parent.client.content:
     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
                     'This will be raising an exception in NiceGUI 1.5')  # DEPRECATED

+ 4 - 0
nicegui/static/nicegui.css

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

+ 19 - 6
nicegui/storage.py

@@ -42,10 +42,11 @@ class ReadOnlyDict(MutableMapping):
 
 
 class PersistentDict(observables.ObservableDict):
 class PersistentDict(observables.ObservableDict):
 
 
-    def __init__(self, filepath: Path) -> None:
+    def __init__(self, filepath: Path, encoding: Optional[str] = None) -> None:
         self.filepath = filepath
         self.filepath = filepath
+        self.encoding = encoding
         try:
         try:
-            data = json.loads(filepath.read_text()) if filepath.exists() else {}
+            data = json.loads(filepath.read_text(encoding)) if filepath.exists() else {}
         except Exception:
         except Exception:
             log.warning(f'Could not load storage file {filepath}')
             log.warning(f'Could not load storage file {filepath}')
             data = {}
             data = {}
@@ -59,7 +60,7 @@ class PersistentDict(observables.ObservableDict):
             self.filepath.parent.mkdir(exist_ok=True)
             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', encoding=self.encoding) as f:
                 await f.write(json.dumps(self))
                 await f.write(json.dumps(self))
         if core.loop:
         if core.loop:
             background_tasks.create_lazy(backup(), name=self.filepath.stem)
             background_tasks.create_lazy(backup(), name=self.filepath.stem)
@@ -93,7 +94,8 @@ class Storage:
 
 
     def __init__(self) -> None:
     def __init__(self) -> None:
         self.path = Path(os.environ.get('NICEGUI_STORAGE_PATH', '.nicegui')).resolve()
         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] = {}
         self._users: Dict[str, PersistentDict] = {}
 
 
     @property
     @property
@@ -132,7 +134,7 @@ class Storage:
             raise RuntimeError('app.storage.user needs a storage_secret passed in ui.run()')
             raise RuntimeError('app.storage.user needs a storage_secret passed in ui.run()')
         session_id = request.session['id']
         session_id = request.session['id']
         if session_id not in self._users:
         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]
         return self._users[session_id]
 
 
     @property
     @property
@@ -144,5 +146,16 @@ class Storage:
         """Clears all storage."""
         """Clears all storage."""
         self._general.clear()
         self._general.clear()
         self._users.clear()
         self._users.clear()
-        for filepath in self.path.glob('storage_*.json'):
+        for filepath in self.path.glob('storage-*.json'):
             filepath.unlink()
             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',
     'select',
     'separator',
     'separator',
     'slider',
     'slider',
+    'space',
     'spinner',
     'spinner',
     'splitter',
     'splitter',
     'step',
     'step',
@@ -160,7 +161,7 @@ from .elements.plotly import Plotly as plotly
 from .elements.progress import CircularProgress as circular_progress
 from .elements.progress import CircularProgress as circular_progress
 from .elements.progress import LinearProgress as linear_progress
 from .elements.progress import LinearProgress as linear_progress
 from .elements.pyplot import Pyplot as pyplot
 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.radio import Radio as radio
 from .elements.row import Row as row
 from .elements.row import Row as row
 from .elements.scene import Scene as scene
 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.select import Select as select
 from .elements.separator import Separator as separator
 from .elements.separator import Separator as separator
 from .elements.slider import Slider as slider
 from .elements.slider import Slider as slider
+from .elements.space import Space as space
 from .elements.spinner import Spinner as spinner
 from .elements.spinner import Spinner as spinner
 from .elements.splitter import Splitter as splitter
 from .elements.splitter import Splitter as splitter
 from .elements.stepper import Step as step
 from .elements.stepper import Step as step

+ 17 - 3
tests/conftest.py

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

+ 26 - 5
tests/screen.py

@@ -38,7 +38,8 @@ class Screen:
         self.server_thread.start()
         self.server_thread.start()
 
 
     @property
     @property
-    def is_open(self) -> None:
+    def is_open(self) -> bool:
+        """Check if the browser is open."""
         # https://stackoverflow.com/a/66150779/3419103
         # https://stackoverflow.com/a/66150779/3419103
         try:
         try:
             self.selenium.current_url  # pylint: disable=pointless-statement
             self.selenium.current_url  # pylint: disable=pointless-statement
@@ -74,31 +75,37 @@ class Screen:
                 if time.time() > deadline:
                 if time.time() > deadline:
                     raise
                     raise
                 time.sleep(0.1)
                 time.sleep(0.1)
+                assert self.server_thread is not None
                 if not self.server_thread.is_alive():
                 if not self.server_thread.is_alive():
                     raise RuntimeError('The NiceGUI server has stopped running') from e
                     raise RuntimeError('The NiceGUI server has stopped running') from e
 
 
     def close(self) -> None:
     def close(self) -> None:
+        """Close the browser."""
         if self.is_open:
         if self.is_open:
             self.selenium.close()
             self.selenium.close()
 
 
     def switch_to(self, tab_id: int) -> None:
     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)
         window_count = len(self.selenium.window_handles)
         if tab_id > window_count:
         if tab_id > window_count:
             raise IndexError(f'Could not go to or create tab {tab_id}, there are only {window_count} tabs')
             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')
             self.selenium.switch_to.new_window('tab')
         else:
         else:
             self.selenium.switch_to.window(self.selenium.window_handles[tab_id])
             self.selenium.switch_to.window(self.selenium.window_handles[tab_id])
 
 
     def should_contain(self, text: str) -> None:
     def should_contain(self, text: str) -> None:
+        """Assert that the page contains the given text."""
         if self.selenium.title == text:
         if self.selenium.title == text:
             return
             return
         self.find(text)
         self.find(text)
 
 
     def wait_for(self, text: str) -> None:
     def wait_for(self, text: str) -> None:
+        """Wait until the page contains the given text."""
         self.should_contain(text)
         self.should_contain(text)
 
 
     def should_not_contain(self, text: str, wait: float = 0.5) -> None:
     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
         assert self.selenium.title != text
         self.selenium.implicitly_wait(wait)
         self.selenium.implicitly_wait(wait)
         with pytest.raises(AssertionError):
         with pytest.raises(AssertionError):
@@ -106,6 +113,7 @@ class Screen:
         self.selenium.implicitly_wait(self.IMPLICIT_WAIT)
         self.selenium.implicitly_wait(self.IMPLICIT_WAIT)
 
 
     def should_contain_input(self, text: str) -> None:
     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
         deadline = time.time() + self.IMPLICIT_WAIT
         while time.time() < deadline:
         while time.time() < deadline:
             for input_element in self.find_all_by_tag('input'):
             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}"')
         raise AssertionError(f'Could not find input with value "{text}"')
 
 
     def should_load_image(self, image: WebElement, *, timeout: float = 2.0) -> None:
     def should_load_image(self, image: WebElement, *, timeout: float = 2.0) -> None:
+        """Assert that the given image has loaded."""
         deadline = time.time() + timeout
         deadline = time.time() + timeout
         while time.time() < deadline:
         while time.time() < deadline:
             js = 'return arguments[0].naturalWidth > 0 && arguments[0].naturalHeight > 0'
             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")}')
         raise AssertionError(f'Image not loaded: {image.get_attribute("outerHTML")}')
 
 
     def click(self, target_text: str) -> WebElement:
     def click(self, target_text: str) -> WebElement:
+        """Click on the element containing the given text."""
         element = self.find(target_text)
         element = self.find(target_text)
         try:
         try:
             element.click()
             element.click()
@@ -131,21 +141,25 @@ class Screen:
         return element
         return element
 
 
     def context_click(self, target_text: str) -> WebElement:
     def context_click(self, target_text: str) -> WebElement:
+        """Right-click on the element containing the given text."""
         element = self.find(target_text)
         element = self.find(target_text)
         action = ActionChains(self.selenium)
         action = ActionChains(self.selenium)
         action.context_click(element).perform()
         action.context_click(element).perform()
         return element
         return element
 
 
     def click_at_position(self, element: WebElement, x: int, y: int) -> None:
     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 = ActionChains(self.selenium)
         action.move_to_element_with_offset(element, x, y).click().perform()
         action.move_to_element_with_offset(element, x, y).click().perform()
 
 
     def type(self, text: str) -> None:
     def type(self, text: str) -> None:
+        """Type the given text into the currently focused element."""
         self.selenium.execute_script("window.focus();")
         self.selenium.execute_script("window.focus();")
         self.wait(0.2)
         self.wait(0.2)
         self.selenium.switch_to.active_element.send_keys(text)
         self.selenium.switch_to.active_element.send_keys(text)
 
 
     def find(self, text: str) -> WebElement:
     def find(self, text: str) -> WebElement:
+        """Find the element containing the given text."""
         try:
         try:
             query = f'//*[not(self::script) and not(self::style) and text()[contains(., "{text}")]]'
             query = f'//*[not(self::script) and not(self::style) and text()[contains(., "{text}")]]'
             element = self.selenium.find_element(By.XPATH, query)
             element = self.selenium.find_element(By.XPATH, query)
@@ -161,35 +175,41 @@ class Screen:
             raise AssertionError(f'Could not find "{text}"') from e
             raise AssertionError(f'Could not find "{text}"') from e
 
 
     def find_all(self, text: str) -> List[WebElement]:
     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}")]]'
         query = f'//*[not(self::script) and not(self::style) and text()[contains(., "{text}")]]'
         return self.selenium.find_elements(By.XPATH, query)
         return self.selenium.find_elements(By.XPATH, query)
 
 
     def find_element(self, element: ui.element) -> WebElement:
     def find_element(self, element: ui.element) -> WebElement:
+        """Find the given NiceGUI element."""
         return self.selenium.find_element(By.ID, f'c{element.id}')
         return self.selenium.find_element(By.ID, f'c{element.id}')
 
 
     def find_by_class(self, name: str) -> WebElement:
     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)
         return self.selenium.find_element(By.CLASS_NAME, name)
 
 
     def find_all_by_class(self, name: str) -> List[WebElement]:
     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)
         return self.selenium.find_elements(By.CLASS_NAME, name)
 
 
     def find_by_tag(self, name: str) -> WebElement:
     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)
         return self.selenium.find_element(By.TAG_NAME, name)
 
 
     def find_all_by_tag(self, name: str) -> List[WebElement]:
     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)
         return self.selenium.find_elements(By.TAG_NAME, name)
 
 
     def render_js_logs(self) -> str:
     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'))
         console = '\n'.join(l['message'] for l in self.selenium.get_log('browser'))
         return f'-- console logs ---\n{console}\n---------------------'
         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:
     def wait(self, t: float) -> None:
+        """Wait for the given number of seconds."""
         time.sleep(t)
         time.sleep(t)
 
 
     def shot(self, name: str) -> None:
     def shot(self, name: str) -> None:
+        """Take a screenshot and store it in the screenshots directory."""
         os.makedirs(self.SCREENSHOT_DIR, exist_ok=True)
         os.makedirs(self.SCREENSHOT_DIR, exist_ok=True)
         filename = f'{self.SCREENSHOT_DIR}/{name}.png'
         filename = f'{self.SCREENSHOT_DIR}/{name}.png'
         print(f'Storing screenshot to {filename}')
         print(f'Storing screenshot to {filename}')
@@ -212,6 +232,7 @@ class Screen:
 
 
     @contextmanager
     @contextmanager
     def implicitly_wait(self, t: float) -> None:
     def implicitly_wait(self, t: float) -> None:
+        """Temporarily change the implicit wait time."""
         self.selenium.implicitly_wait(t)
         self.selenium.implicitly_wait(t)
         yield
         yield
         self.selenium.implicitly_wait(self.IMPLICIT_WAIT)
         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
     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({
     grid = ui.aggrid({
         'columnDefs': [{'field': 'name', 'filter': True}],
         'columnDefs': [{'field': 'name', 'filter': True}],
         'rowData': [{'name': 'Alice'}, {'name': 'Bob'}, {'name': 'Carol'}],
         'rowData': [{'name': 'Alice'}, {'name': 'Bob'}, {'name': 'Carol'}],
     })
     })
     filter_model = {'name': {'filterType': 'text', 'type': 'equals', 'filter': 'Alice'}}
     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.open('/')
     screen.should_contain('Alice')
     screen.should_contain('Alice')
@@ -108,12 +108,12 @@ def test_call_api_method_with_argument(screen: Screen):
     screen.should_not_contain('Carol')
     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({
     grid = ui.aggrid({
         'columnDefs': [{'field': 'name'}, {'field': 'age', 'hide': True}],
         'columnDefs': [{'field': 'name'}, {'field': 'age', 'hide': True}],
         'rowData': [{'name': 'Alice', 'age': '18'}, {'name': 'Bob', 'age': '21'}, {'name': 'Carol', 'age': '42'}],
         '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.open('/')
     screen.should_contain('Alice')
     screen.should_contain('Alice')
@@ -188,7 +188,7 @@ def test_create_dynamically(screen: Screen):
 
 
 def test_api_method_after_creation(screen: Screen):
 def test_api_method_after_creation(screen: Screen):
     options = {'columnDefs': [{'field': 'name'}], 'rowData': [{'name': 'Alice'}]}
     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.open('/')
     screen.click('Create')
     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')
     canvas = screen.find_by_tag('canvas')
     assert canvas.rect['height'] == 168
     assert canvas.rect['height'] == 168
     assert canvas.rect['width'] == 568
     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')
     element.send_keys('John')
     screen.should_contain('Too short')
     screen.should_contain('Too short')
     assert input_.error == 'Too short'
     assert input_.error == 'Too short'
+    assert not input_.validate()
 
 
     element.send_keys(' Doe')
     element.send_keys(' Doe')
     screen.wait(0.5)
     screen.wait(0.5)
     screen.should_not_contain('Too short')
     screen.should_not_contain('Too short')
     assert input_.error is None
     assert input_.error is None
+    assert input_.validate()
 
 
 
 
 def test_input_with_multi_word_error_message(screen: Screen):
 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('/')
     screen.open('/')
 
 
-    def position() -> None:
+    def position() -> float:
         for _ in range(3):
         for _ in range(3):
             try:
             try:
                 pos = screen.selenium.execute_script(f'return scene_c{scene.id}.getObjectByName("sphere").position.z')
                 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 asyncio
-import warnings
 from pathlib import Path
 from pathlib import Path
 
 
 import httpx
 import httpx
@@ -91,7 +90,7 @@ async def test_access_user_storage_from_fastapi(screen: Screen):
         assert response.status_code == 200
         assert response.status_code == 200
         assert response.text == '"OK"'
         assert response.text == '"OK"'
         await asyncio.sleep(0.5)  # wait for storage to be written
         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):
 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.open('/')
     screen.click('switch')
     screen.click('switch')
     screen.wait(0.5)
     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):
 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.open('/')
     screen.click('test')
     screen.click('test')
     screen.wait(1)
     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):
 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.ui_run_kwargs['storage_secret'] = 'just a test'
     screen.open('/')
     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):
 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):
 def test_rapid_storage(screen: Screen):
     # https://github.com/zauberzeug/nicegui/issues/1099
     # https://github.com/zauberzeug/nicegui/issues/1099
-    warnings.simplefilter('error')
-
     ui.button('test', on_click=lambda: (
     ui.button('test', on_click=lambda: (
         app.storage.general.update(one=1),
         app.storage.general.update(one=1),
         app.storage.general.update(two=2),
         app.storage.general.update(two=2),
@@ -166,4 +163,4 @@ def test_rapid_storage(screen: Screen):
     screen.open('/')
     screen.open('/')
     screen.click('test')
     screen.click('test')
     screen.wait(0.5)
     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 asyncio
-import warnings
 
 
 import pytest
 import pytest
 
 
@@ -80,15 +79,17 @@ def test_setting_visibility(screen: Screen, once: bool):
 
 
 
 
 def test_awaiting_coroutine(screen: Screen):
 def test_awaiting_coroutine(screen: Screen):
-    warnings.simplefilter('error')
+    user = {'name': 'John Doe'}
 
 
     async def update_user():
     async def update_user():
         await asyncio.sleep(0.1)
         await asyncio.sleep(0.1)
+        user['name'] = 'Jane Doe'
 
 
     ui.timer(1, update_user)
     ui.timer(1, update_user)
 
 
     screen.open('/')
     screen.open('/')
     screen.wait(1)
     screen.wait(1)
+    assert user['name'] == 'Jane Doe'
 
 
 
 
 def test_timer_on_deleted_container(screen: Screen):
 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()
         grid.update()
 
 
     ui.button('Update', on_click=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', '''
 @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)
 doc.reference(ui.echart)

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

@@ -16,4 +16,22 @@ def main_demo() -> None:
         ui.label('1.80m')
         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)
 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)
 @doc.demo(ui.interactive_image)
 def main_demo() -> None:
 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'
         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" />'
         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})')
         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)
     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)
 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}'))
                    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)
 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)
     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)
 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
     with ui.column().classes('h-full w-full bg-gray-400 justify-between'):  # HIDE
         ui.label('top left')
         ui.label('top left')
         ui.label('bottom right').classes('self-end')
         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,
 from . import (card_documentation, carousel_documentation, column_documentation, context_menu_documentation,
                dialog_documentation, doc, expansion_documentation, grid_documentation, menu_documentation,
                dialog_documentation, doc, expansion_documentation, grid_documentation, menu_documentation,
                notification_documentation, notify_documentation, pagination_documentation, row_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*')
 doc.title('Page *Layout*')
 
 
@@ -63,6 +63,7 @@ def clear_containers_demo():
 doc.intro(expansion_documentation)
 doc.intro(expansion_documentation)
 doc.intro(scroll_area_documentation)
 doc.intro(scroll_area_documentation)
 doc.intro(separator_documentation)
 doc.intro(separator_documentation)
+doc.intro(space_documentation)
 doc.intro(splitter_documentation)
 doc.intro(splitter_documentation)
 doc.intro(tabs_documentation)
 doc.intro(tabs_documentation)
 doc.intro(stepper_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'))
     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.tabs')
 doc.reference(ui.tabs, title='Reference for ui.tab')
 doc.reference(ui.tabs, title='Reference for ui.tab')
 doc.reference(ui.tabs, title='Reference for ui.tab_panels')
 doc.reference(ui.tabs, title='Reference for ui.tab_panels')