Parcourir la source

Merge branch 'v1.3' into on-air

# Conflicts:
#	nicegui/templates/index.html
Falko Schindler il y a 1 an
Parent
commit
d73e934431

+ 2 - 2
.github/workflows/test.yml

@@ -6,7 +6,7 @@ jobs:
   test:
   test:
     strategy:
     strategy:
       matrix:
       matrix:
-        python: ["3.7", "3.8", "3.9", "3.10", "3.11"]
+        python: ["3.8", "3.9", "3.10", "3.11"]
       fail-fast: false
       fail-fast: false
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     timeout-minutes: 40
     timeout-minutes: 40
@@ -23,7 +23,7 @@ jobs:
       - name: install dependencies
       - name: install dependencies
         run: |
         run: |
           poetry config virtualenvs.create false
           poetry config virtualenvs.create false
-          poetry install
+          poetry install --all-extras
           # install packages to run the examples
           # install packages to run the examples
           pip install opencv-python opencv-contrib-python-headless httpx replicate langchain openai simpy tortoise-orm
           pip install opencv-python opencv-contrib-python-headless httpx replicate langchain openai simpy tortoise-orm
           pip install -r tests/requirements.txt
           pip install -r tests/requirements.txt

+ 2 - 2
development.dockerfile

@@ -1,4 +1,4 @@
-FROM python:3.7.16-slim
+FROM python:3.8-slim
 
 
 RUN apt update && apt install curl -y
 RUN apt update && apt install curl -y
 
 
@@ -11,6 +11,6 @@ RUN curl -sSL https://install.python-poetry.org | python3 - && \
 WORKDIR /app
 WORKDIR /app
 
 
 COPY ./pyproject.toml ./poetry.lock* main.py ./
 COPY ./pyproject.toml ./poetry.lock* main.py ./
-RUN poetry install --no-root
+RUN poetry install --no-root --all-extras
 
 
 CMD python3 -m debugpy --listen 5678 main.py
 CMD python3 -m debugpy --listen 5678 main.py

+ 1 - 1
examples/lightbox/main.py

@@ -14,7 +14,7 @@ class Lightbox:
     def __init__(self) -> None:
     def __init__(self) -> None:
         with ui.dialog().props('maximized').classes('bg-black') as self.dialog:
         with ui.dialog().props('maximized').classes('bg-black') as self.dialog:
             ui.keyboard(self._on_key)
             ui.keyboard(self._on_key)
-            self.large_image = ui.image().props('no-spinner')
+            self.large_image = ui.image().props('no-spinner fit=scale-down')
         self.image_list: List[str] = []
         self.image_list: List[str] = []
 
 
     def add_image(self, thumb_url: str, orig_url: str) -> ui.image:
     def add_image(self, thumb_url: str, orig_url: str) -> ui.image:

+ 0 - 1
nicegui.code-workspace

@@ -13,7 +13,6 @@
     "recommendations": [
     "recommendations": [
       "ms-python.vscode-pylance",
       "ms-python.vscode-pylance",
       "ms-python.python",
       "ms-python.python",
-      "himanoa.python-autopep8",
       "esbenp.prettier-vscode",
       "esbenp.prettier-vscode",
       "littlefoxteam.vscode-python-test-adapter",
       "littlefoxteam.vscode-python-test-adapter",
       "cschleiden.vscode-github-actions",
       "cschleiden.vscode-github-actions",

+ 1 - 1
nicegui/client.py

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

+ 45 - 35
nicegui/dependencies.py

@@ -1,5 +1,6 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
+import hashlib
 from dataclasses import dataclass
 from dataclasses import dataclass
 from pathlib import Path
 from pathlib import Path
 from typing import TYPE_CHECKING, Dict, List, Set, Tuple
 from typing import TYPE_CHECKING, Dict, List, Set, Tuple
@@ -17,6 +18,7 @@ if TYPE_CHECKING:
 class Component:
 class Component:
     key: str
     key: str
     name: str
     name: str
+    path: Path
 
 
     @property
     @property
     def tag(self) -> str:
     def tag(self) -> str:
@@ -32,7 +34,7 @@ class VueComponent(Component):
 
 
 @dataclass(**KWONLY_SLOTS)
 @dataclass(**KWONLY_SLOTS)
 class JsComponent(Component):
 class JsComponent(Component):
-    path: Path
+    pass
 
 
 
 
 @dataclass(**KWONLY_SLOTS)
 @dataclass(**KWONLY_SLOTS)
@@ -48,52 +50,59 @@ js_components: Dict[str, JsComponent] = {}
 libraries: Dict[str, Library] = {}
 libraries: Dict[str, Library] = {}
 
 
 
 
-def register_vue_component(location: Path, base_path: Path = Path(__file__).parent / 'elements') -> Component:
+def register_vue_component(path: Path) -> Component:
     """Register a .vue or .js Vue component.
     """Register a .vue or .js Vue component.
 
 
     Single-file components (.vue) are built right away
     Single-file components (.vue) are built right away
     to delegate this "long" process to the bootstrap phase
     to delegate this "long" process to the bootstrap phase
     and to avoid building the component on every single request.
     and to avoid building the component on every single request.
-
-    :param location: location to the library relative to the base_path (used as the resource identifier, must be URL-safe)
-    :param base_path: base path where your libraries are located
-    :return: registered component
     """
     """
-    path, key, name, suffix = deconstruct_location(location, base_path)
-    if suffix == '.vue':
+    key = compute_key(path)
+    name = get_name(path)
+    if path.suffix == '.vue':
+        if key in vue_components and vue_components[key].path == path:
+            return vue_components[key]
         assert key not in vue_components, f'Duplicate VUE component {key}'
         assert key not in vue_components, f'Duplicate VUE component {key}'
-        build = vbuild.VBuild(name, path.read_text())
-        vue_components[key] = VueComponent(key=key, name=name, html=build.html, script=build.script, style=build.style)
+        v = vbuild.VBuild(name, path.read_text())
+        vue_components[key] = VueComponent(key=key, name=name, path=path, html=v.html, script=v.script, style=v.style)
         return vue_components[key]
         return vue_components[key]
-    if suffix == '.js':
+    if path.suffix == '.js':
+        if key in js_components and js_components[key].path == path:
+            return js_components[key]
         assert key not in js_components, f'Duplicate JS component {key}'
         assert key not in js_components, f'Duplicate JS component {key}'
         js_components[key] = JsComponent(key=key, name=name, path=path)
         js_components[key] = JsComponent(key=key, name=name, path=path)
         return js_components[key]
         return js_components[key]
-    raise ValueError(f'Unsupported component type "{suffix}"')
+    raise ValueError(f'Unsupported component type "{path.suffix}"')
 
 
 
 
-def register_library(location: Path, base_path: Path = Path(__file__).parent / 'elements' / 'lib', *,
-                     expose: bool = False) -> Library:
-    """Register a *.js library.
-
-    :param location: location to the library relative to the base_path (used as the resource identifier, must be URL-safe)
-    :param base_path: base path where your libraries are located
-    :param expose: whether to expose library as an ESM module (exposed modules will NOT be imported)
-    :return: registered library
-    """
-    path, key, name, suffix = deconstruct_location(location, base_path)
-    if suffix in {'.js', '.mjs'}:
+def register_library(path: Path, *, expose: bool = False) -> Library:
+    """Register a *.js library."""
+    key = compute_key(path)
+    name = get_name(path)
+    if path.suffix in {'.js', '.mjs'}:
+        if key in libraries and libraries[key].path == path:
+            return libraries[key]
         assert key not in libraries, f'Duplicate js library {key}'
         assert key not in libraries, f'Duplicate js library {key}'
         libraries[key] = Library(key=key, name=name, path=path, expose=expose)
         libraries[key] = Library(key=key, name=name, path=path, expose=expose)
         return libraries[key]
         return libraries[key]
-    raise ValueError(f'Unsupported library type "{suffix}"')
+    raise ValueError(f'Unsupported library type "{path.suffix}"')
+
+
+def compute_key(path: Path) -> str:
+    """Compute a key for a given path using a hash function.
+
+    If the path is relative to the NiceGUI base directory, the key is computed from the relative path.
+    """
+    nicegui_base = Path(__file__).parent
+    try:
+        path = path.relative_to(nicegui_base)
+    except ValueError:
+        pass
+    return f'{hashlib.sha256(str(path.parent).encode()).hexdigest()}/{path.name}'
 
 
 
 
-def deconstruct_location(location: Path, base_path: Path) -> Tuple[Path, str, str, str]:
-    """Deconstruct a location into its parts: full path, relative path, name, suffix."""
-    abs_path = location if location.is_absolute() else base_path / location
-    rel_path = location if not location.is_absolute() else location.relative_to(base_path)
-    return abs_path, str(rel_path), location.name.split('.', 1)[0], location.suffix.lower()
+def get_name(path: Path) -> str:
+    return path.name.split('.', 1)[0]
 
 
 
 
 def generate_resources(prefix: str, elements: List[Element]) -> Tuple[List[str],
 def generate_resources(prefix: str, elements: List[Element]) -> Tuple[List[str],
@@ -129,13 +138,14 @@ def generate_resources(prefix: str, elements: List[Element]) -> Tuple[List[str],
         for library in element.libraries:
         for library in element.libraries:
             if library.key not in done_libraries:
             if library.key not in done_libraries:
                 if not library.expose:
                 if not library.expose:
-                    js_imports.append(f'import "{prefix}/_nicegui/{__version__}/libraries/{library.key}";')
+                    url = f'{prefix}/_nicegui/{__version__}/libraries/{library.key}'
+                    js_imports.append(f'import "{url}";')
                 done_libraries.add(library.key)
                 done_libraries.add(library.key)
-        for component in element.components:
+        if element.component:
+            component = element.component
             if component.key not in done_components and component.path.suffix.lower() == '.js':
             if component.key not in done_components and component.path.suffix.lower() == '.js':
-                js_imports.extend([
-                    f'import {{ default as {component.name} }} from "{prefix}/_nicegui/{__version__}/components/{component.key}";',
-                    f'app.component("{component.tag}", {component.name});',
-                ])
+                url = f'{prefix}/_nicegui/{__version__}/components/{component.key}'
+                js_imports.append(f'import {{ default as {component.name} }} from "{url}";')
+                js_imports.append(f'app.component("{component.tag}", {component.name});')
                 done_components.add(component.key)
                 done_components.add(component.key)
     return vue_html, vue_styles, vue_scripts, imports, js_imports
     return vue_html, vue_styles, vue_scripts, imports, js_imports

+ 39 - 8
nicegui/element.py

@@ -24,7 +24,7 @@ PROPS_PATTERN = re.compile(r'([:\w\-]+)(?:=(?:("[^"\\]*(?:\\.[^"\\]*)*")|([\w\-.
 
 
 
 
 class Element(Visibility):
 class Element(Visibility):
-    components: List[JsComponent] = []
+    component: Optional[JsComponent] = None
     libraries: List[Library] = []
     libraries: List[Library] = []
     extra_libraries: List[Library] = []
     extra_libraries: List[Library] = []
     exposed_libraries: List[Library] = []
     exposed_libraries: List[Library] = []
@@ -42,7 +42,7 @@ class Element(Visibility):
         self.client = _client or globals.get_client()
         self.client = _client or globals.get_client()
         self.id = self.client.next_element_id
         self.id = self.client.next_element_id
         self.client.next_element_id += 1
         self.client.next_element_id += 1
-        self.tag = tag if tag else self.components[0].tag if self.components else 'div'
+        self.tag = tag if tag else self.component.tag if self.component else 'div'
         self._classes: List[str] = []
         self._classes: List[str] = []
         self._style: Dict[str, str] = {}
         self._style: Dict[str, str] = {}
         self._props: Dict[str, Any] = {'key': self.id}  # HACK: workaround for #600 and #898
         self._props: Dict[str, Any] = {'key': self.id}  # HACK: workaround for #600 and #898
@@ -72,10 +72,32 @@ class Element(Visibility):
                           ) -> None:
                           ) -> None:
         super().__init_subclass__()
         super().__init_subclass__()
         base = Path(inspect.getfile(cls)).parent
         base = Path(inspect.getfile(cls)).parent
-        cls.components = [register_vue_component(Path(component), base)] if component else []
-        cls.libraries = [register_library(Path(library), base) for library in libraries]
-        cls.extra_libraries = [register_library(Path(library), base) for library in extra_libraries]
-        cls.exposed_libraries = [register_library(Path(library), base, expose=True) for library in exposed_libraries]
+
+        def glob_absolute_paths(file: Union[str, Path]) -> List[Path]:
+            path = Path(file)
+            if not path.is_absolute():
+                path = base / path
+            return sorted(path.parent.glob(path.name), key=lambda p: p.stem)
+
+        cls.component = None
+        if component:
+            for path in glob_absolute_paths(component):
+                cls.component = register_vue_component(path)
+
+        cls.libraries = []
+        for library in libraries:
+            for path in glob_absolute_paths(library):
+                cls.libraries.append(register_library(path))
+
+        cls.extra_libraries = []
+        for library in extra_libraries:
+            for path in glob_absolute_paths(library):
+                cls.extra_libraries.append(register_library(path))
+
+        cls.exposed_libraries = []
+        for library in exposed_libraries:
+            for path in glob_absolute_paths(library):
+                cls.exposed_libraries.append(register_library(path, expose=True))
 
 
     def add_slot(self, name: str, template: Optional[str] = None) -> Slot:
     def add_slot(self, name: str, template: Optional[str] = None) -> Slot:
         """Add a slot to the element.
         """Add a slot to the element.
@@ -115,8 +137,17 @@ class Element(Visibility):
             'text': self._text,
             'text': self._text,
             'slots': self._collect_slot_dict(),
             'slots': self._collect_slot_dict(),
             'events': [listener.to_dict() for listener in self._event_listeners.values()],
             'events': [listener.to_dict() for listener in self._event_listeners.values()],
-            'components': [{'key': c.key, 'name': c.name, 'tag': c.tag} for c in self.components],
-            'libraries': [{'key': l.key, 'name': l.name} for l in self.libraries],
+            'component': {
+                'key': self.component.key,
+                'name': self.component.name,
+                'tag': self.component.tag
+            } if self.component else None,
+            'libraries': [
+                {
+                    'key': library.key,
+                    'name': library.name,
+                } for library in self.libraries
+            ],
         }
         }
 
 
     @staticmethod
     @staticmethod

+ 4 - 6
nicegui/elements/chart.py

@@ -1,14 +1,12 @@
-from pathlib import Path
 from typing import Dict, List
 from typing import Dict, List
 
 
 from ..element import Element
 from ..element import Element
 
 
-base = Path(__file__).parent
-libraries = [p.relative_to(base) for p in sorted((base / 'lib' / 'highcharts').glob('*.js'), key=lambda p: p.stem)]
-modules = {p.stem: p.relative_to(base) for p in sorted((base / 'lib' / 'highcharts' / 'modules').glob('*.js'))}
 
 
-
-class Chart(Element, component='chart.js', libraries=libraries, extra_libraries=list(modules.values())):
+class Chart(Element,
+            component='chart.js',
+            libraries=['lib/highcharts/*.js'],
+            extra_libraries=['lib/highcharts/modules/*.js']):
 
 
     def __init__(self, options: Dict, *, type: str = 'chart', extras: List[str] = []) -> None:
     def __init__(self, options: Dict, *, type: str = 'chart', extras: List[str] = []) -> None:
         """Chart
         """Chart

+ 4 - 2
nicegui/elements/knob.py

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

+ 0 - 6
nicegui/elements/markdown.js

@@ -37,9 +37,3 @@ export default {
     },
     },
   },
   },
 };
 };
-
-function decodeHtml(html) {
-  const txt = document.createElement("textarea");
-  txt.innerHTML = html;
-  return txt.value;
-}

+ 16 - 1
nicegui/elements/mermaid.js

@@ -1,14 +1,29 @@
 import mermaid from "mermaid";
 import mermaid from "mermaid";
+
+let is_running = false;
+const queue = [];
+
 export default {
 export default {
   template: `<div></div>`,
   template: `<div></div>`,
+  data: () => ({
+    last_content: "",
+  }),
   mounted() {
   mounted() {
     this.update(this.content);
     this.update(this.content);
   },
   },
   methods: {
   methods: {
     async update(content) {
     async update(content) {
+      if (this.last_content === content) return;
+      this.last_content = content;
       this.$el.innerHTML = content;
       this.$el.innerHTML = content;
       this.$el.removeAttribute("data-processed");
       this.$el.removeAttribute("data-processed");
-      await mermaid.run({ nodes: [this.$el] });
+      queue.push(this.$el);
+      if (is_running) return;
+      is_running = true;
+      while (queue.length) {
+        await mermaid.run({ nodes: [queue.shift()] });
+      }
+      is_running = false;
     },
     },
   },
   },
   props: {
   props: {

+ 1 - 5
nicegui/elements/mermaid.py

@@ -1,14 +1,10 @@
-from pathlib import Path
-
 from .mixins.content_element import ContentElement
 from .mixins.content_element import ContentElement
 
 
-base = Path(__file__).parent
-
 
 
 class Mermaid(ContentElement,
 class Mermaid(ContentElement,
               component='mermaid.js',
               component='mermaid.js',
               exposed_libraries=['lib/mermaid/mermaid.esm.min.mjs'],
               exposed_libraries=['lib/mermaid/mermaid.esm.min.mjs'],
-              extra_libraries=[p.relative_to(base) for p in (base / 'lib' / 'mermaid').glob('*.js')]):
+              extra_libraries=['lib/mermaid/*.js']):
     CONTENT_PROP = 'content'
     CONTENT_PROP = 'content'
 
 
     def __init__(self, content: str) -> None:
     def __init__(self, content: str) -> None:

+ 2 - 0
nicegui/elements/query.py

@@ -59,6 +59,8 @@ def query(selector: str) -> Query:
     To manipulate elements like the document body, you can use the `ui.query` function.
     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.
     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')`).
     This can be useful for example to change the background color of the page (e.g. `ui.query('body').classes('bg-green')`).
+
+    :param selector: the CSS selector (e.g. "body", "#my-id", ".my-class", "div > p")
     """
     """
     for element in get_client().elements.values():
     for element in get_client().elements.values():
         if isinstance(element, Query) and element._props['selector'] == selector:
         if isinstance(element, Query) and element._props['selector'] == selector:

+ 70 - 0
nicegui/elements/scroll_area.py

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

+ 1 - 0
nicegui/elements/table.py

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

+ 1 - 1
nicegui/elements/textarea.py

@@ -3,7 +3,7 @@ from typing import Any, Callable, Dict, Optional
 from .input import Input
 from .input import Input
 
 
 
 
-class Textarea(Input):
+class Textarea(Input, component='input.js'):
 
 
     def __init__(self,
     def __init__(self,
                  label: Optional[str] = None, *,
                  label: Optional[str] = None, *,

+ 14 - 4
nicegui/events.py

@@ -280,6 +280,18 @@ class KeyEventArguments(EventArguments):
     modifiers: KeyboardModifiers
     modifiers: KeyboardModifiers
 
 
 
 
+@dataclass(**KWONLY_SLOTS)
+class ScrollEventArguments(EventArguments):
+    vertical_position: float
+    vertical_percentage: float
+    vertical_size: float
+    vertical_container_size: float
+    horizontal_position: float
+    horizontal_percentage: float
+    horizontal_size: float
+    horizontal_container_size: float
+
+
 def handle_event(handler: Optional[Callable[..., Any]], arguments: EventArguments) -> None:
 def handle_event(handler: Optional[Callable[..., Any]], arguments: EventArguments) -> None:
     if handler is None:
     if handler is None:
         return
         return
@@ -288,11 +300,9 @@ def handle_event(handler: Optional[Callable[..., Any]], arguments: EventArgument
                                 p.kind is not Parameter.VAR_POSITIONAL and
                                 p.kind is not Parameter.VAR_POSITIONAL and
                                 p.kind is not Parameter.VAR_KEYWORD
                                 p.kind is not Parameter.VAR_KEYWORD
                                 for p in signature(handler).parameters.values())
                                 for p in signature(handler).parameters.values())
-        sender = arguments.sender if isinstance(arguments, EventArguments) else sender
-        assert sender is not None and sender.parent_slot is not None
-        if sender.is_ignoring_events:
+        if arguments.sender.is_ignoring_events:
             return
             return
-        with sender.parent_slot:
+        with arguments.sender.parent_slot:
             result = handler(arguments) if expects_arguments else handler()
             result = handler(arguments) if expects_arguments else handler()
         if isinstance(result, Awaitable):
         if isinstance(result, Awaitable):
             async def wait_for_result():
             async def wait_for_result():

+ 2 - 2
nicegui/favicon.py

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

+ 1 - 0
nicegui/globals.py

@@ -33,6 +33,7 @@ loop: Optional[asyncio.AbstractEventLoop] = None
 log: logging.Logger = logging.getLogger('nicegui')
 log: logging.Logger = logging.getLogger('nicegui')
 state: State = State.STOPPED
 state: State = State.STOPPED
 ui_run_has_been_called: bool = False
 ui_run_has_been_called: bool = False
+optional_features: List[str] = []
 
 
 reload: bool
 reload: bool
 title: str
 title: str

+ 12 - 1
nicegui/helpers.py

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

+ 2 - 1
nicegui/native_mode.py

@@ -41,7 +41,8 @@ def open_window(
         start_window_method_executor(window, method_queue, response_queue, closing)
         start_window_method_executor(window, method_queue, response_queue, closing)
         webview.start(storage_path=tempfile.mkdtemp(), **globals.app.native.start_args)
         webview.start(storage_path=tempfile.mkdtemp(), **globals.app.native.start_args)
     except NameError:
     except NameError:
-        logging.error('Native mode is not supported in this configuration. Please install pywebview to use it.')
+        logging.error('''Native mode is not supported in this configuration.
+Please run "pip install pywebview" to use it.''')
         sys.exit(1)
         sys.exit(1)
 
 
 
 

+ 2 - 8
nicegui/nicegui.py

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

+ 4 - 0
nicegui/static/nicegui.css

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

+ 67 - 43
nicegui/templates/index.html

@@ -105,7 +105,7 @@
         }
         }
 
 
         // @todo: Try avoid this with better handling of initial page load.
         // @todo: Try avoid this with better handling of initial page load.
-        element.components.forEach((component) => loaded_components.add(component.name));
+        if (element.component) loaded_components.add(element.component.name);
         element.libraries.forEach((library) => loaded_libraries.add(library.name));
         element.libraries.forEach((library) => loaded_libraries.add(library.name));
 
 
         const props = {
         const props = {
@@ -190,11 +190,13 @@
       }
       }
 
 
       async function loadDependencies(element) {
       async function loadDependencies(element) {
-        for (const {name, key, tag} of element.components) {
-          if (loaded_components.has(name)) continue;
-          const component = (await import(`{{ prefix | safe }}/_nicegui/{{version}}/components/${key}`)).default;
-          app = app.component(tag, component);
-          loaded_components.add(name);
+        if (element.component) {
+          const {name, key, tag} = element.component;
+          if (!loaded_components.has(name) && !key.endsWith('.vue')) {
+            const component = await import(`{{ prefix | safe }}/_nicegui/{{version}}/components/${key}`);
+            app = app.component(tag, component.default);
+            loaded_components.add(name);
+          }
         }
         }
         for (const {name, key} of element.libraries) {
         for (const {name, key} of element.libraries) {
           if (loaded_libraries.has(name)) continue;
           if (loaded_libraries.has(name)) continue;
@@ -220,44 +222,66 @@
           const transports = ['websocket', 'polling'];
           const transports = ['websocket', 'polling'];
           window.path_prefix = "{{ prefix | safe }}";
           window.path_prefix = "{{ prefix | safe }}";
           window.socket = io(url, { path: "{{ prefix | safe }}/_nicegui_ws/socket.io", query, extraHeaders, transports });
           window.socket = io(url, { path: "{{ prefix | safe }}/_nicegui_ws/socket.io", query, extraHeaders, transports });
-          window.socket.on("connect", () => {
-            window.socket.emit("handshake", (ok) => {
-              if (!ok) window.location.reload();
-              document.getElementById('popup').style.opacity = 0;
+          const messageHandlers = {
+            connect: () => {
+              window.socket.emit("handshake", (ok) => {
+                if (!ok) window.location.reload();
+                document.getElementById('popup').style.opacity = 0;
+              });
+            },
+            connect_error: (err) => {
+              if (err.message == 'timeout') window.location.reload(); // see https://github.com/zauberzeug/nicegui/issues/198
+            },
+            try_reconnect: () => {
+              const checkAndReload = async () => {
+                await fetch(window.location.href, { headers: { 'NiceGUI-Check': 'try_reconnect' } });
+                window.location.reload();
+              };
+              setInterval(checkAndReload, 500);
+            },
+            disconnect: () => {
+              document.getElementById('popup').style.opacity = 1;
+            },
+            update: async (msg) => {
+              for (const [id, element] of Object.entries(msg)) {
+                await loadDependencies(element);
+                this.elements[element.id] = element;
+              }
+            },
+            run_method: (msg) => {
+              const element = getElement(msg.id);
+              if (element === null || element === undefined) return;
+              if (msg.name in element) {
+                element[msg.name](...msg.args);
+              } else {
+                element.$refs.qRef[msg.name](...msg.args);
+              }
+            },
+            run_javascript: (msg) => runJavascript(msg['code'], msg['request_id']),
+            open: (msg) => (location.href = msg.startsWith('/') ? "{{ prefix | safe }}" + msg : msg),
+            download: (msg) => download(msg.url, msg.filename),
+            notify: (msg) => Quasar.Notify.create(msg),
+          };
+          const socketMessageQueue = [];
+          let isProcessingSocketMessage = false;
+          for (const [event, handler] of Object.entries(messageHandlers)) {
+            window.socket.on(event, async (...args) => {
+              socketMessageQueue.push(() => handler(...args));
+              if (!isProcessingSocketMessage) {
+                while (socketMessageQueue.length > 0) {
+                  const handler = socketMessageQueue.shift()
+                  isProcessingSocketMessage = true;
+                  try {
+                    await handler();
+                  }
+                  catch (e) {
+                    console.error(e);
+                  }
+                  isProcessingSocketMessage = false;
+                }
+              }
             });
             });
-          });
-          window.socket.on("connect_error", (err) => {
-            if (err.message == 'timeout') window.location.reload(); // see https://github.com/zauberzeug/nicegui/issues/198
-          });
-          window.socket.on("disconnect", () => {
-            document.getElementById('popup').style.opacity = 1;
-          });
-          window.socket.on("try_reconnect", () => {
-            const checkAndReload = async () => {
-              await fetch(window.location.href, { headers: { 'NiceGUI-Check': 'try_reconnect' } });
-              window.location.reload();
-            };
-            setInterval(checkAndReload, 500);
-          });
-          window.socket.on("update", async (msg) => {
-            for (const [id, element] of Object.entries(msg)) {
-              await loadDependencies(element);
-              this.elements[element.id] = element;
-            }
-          });
-          window.socket.on("run_method", (msg) => {
-            const element = getElement(msg.id);
-            if (element === null || element === undefined) return;
-            if (msg.name in element) {
-              element[msg.name](...msg.args);
-            } else {
-              element.$refs.qRef[msg.name](...msg.args);
-            }
-          });
-          window.socket.on("run_javascript", (msg) => runJavascript(msg['code'], msg['request_id']));
-          window.socket.on("open", (msg) => (location.href = msg.startsWith('/') ? "{{ prefix | safe }}" + msg : msg));
-          window.socket.on("download", (msg) => download(msg.url, msg.filename));
-          window.socket.on("notify", (msg) => Quasar.Notify.create(msg));
+          }
         },
         },
       }).use(Quasar, {
       }).use(Quasar, {
         config: {
         config: {

+ 25 - 5
nicegui/ui.py

@@ -40,13 +40,13 @@ __all__ = [
     'menu_item',
     'menu_item',
     'mermaid',
     'mermaid',
     'number',
     'number',
-    'plotly',
     'circular_progress',
     'circular_progress',
     'linear_progress',
     'linear_progress',
     'query',
     'query',
     'radio',
     'radio',
     'row',
     'row',
     'scene',
     'scene',
+    'scroll_area',
     'select',
     'select',
     'separator',
     'separator',
     'slider',
     'slider',
@@ -127,13 +127,13 @@ from .elements.menu import Menu as menu
 from .elements.menu import MenuItem as menu_item
 from .elements.menu import MenuItem as menu_item
 from .elements.mermaid import Mermaid as mermaid
 from .elements.mermaid import Mermaid as mermaid
 from .elements.number import Number as number
 from .elements.number import Number as number
-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.query import query
 from .elements.query import 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
+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
@@ -173,8 +173,28 @@ from .page_layout import RightDrawer as right_drawer
 from .run import run
 from .run import run
 from .run_with import run_with
 from .run_with import run_with
 
 
+from .globals import optional_features
+try:
+    from .elements.plotly import Plotly as plotly
+    optional_features.append('plotly')
+except ImportError:
+    def plotly(*args, **kwargs):
+        raise ImportError('Plotly is not installed. Please run "pip install plotly".')
+__all__.append('plotly')
+
 if os.environ.get('MATPLOTLIB', 'true').lower() == 'true':
 if os.environ.get('MATPLOTLIB', 'true').lower() == 'true':
-    from .elements.line_plot import LinePlot as line_plot
-    from .elements.pyplot import Pyplot as pyplot
-    plot = deprecated(pyplot, 'ui.plot', 'ui.pyplot', 317)
+    try:
+        from .elements.line_plot import LinePlot as line_plot
+        from .elements.pyplot import Pyplot as pyplot
+        plot = deprecated(pyplot, 'ui.plot', 'ui.pyplot', 317)
+        optional_features.append('matplotlib')
+    except ImportError:
+        def line_plot(*args, **kwargs):
+            raise ImportError('Matplotlib is not installed. Please run "pip install matplotlib".')
+
+        def pyplot(*args, **kwargs):
+            raise ImportError('Matplotlib is not installed. Please run "pip install matplotlib".')
+
+        def plot(*args, **kwargs):
+            raise ImportError('Matplotlib is not installed. Please run "pip install matplotlib".')
     __all__.extend(['line_plot', 'pyplot', 'plot'])
     __all__.extend(['line_plot', 'pyplot', 'plot'])

Fichier diff supprimé car celui-ci est trop grand
+ 298 - 514
poetry.lock


+ 10 - 7
pyproject.toml

@@ -9,27 +9,30 @@ repository = "https://github.com/zauberzeug/nicegui"
 keywords = ["gui", "ui", "web", "interface", "live"]
 keywords = ["gui", "ui", "web", "interface", "live"]
 
 
 [tool.poetry.dependencies]
 [tool.poetry.dependencies]
-python = "^3.7"
+python = "^3.8"
 typing-extensions = ">=3.10.0"
 typing-extensions = ">=3.10.0"
 markdown2 = "^2.4.7"
 markdown2 = "^2.4.7"
 Pygments = ">=2.9.0,<3.0.0"
 Pygments = ">=2.9.0,<3.0.0"
 uvicorn = {extras = ["standard"], version = "^0.22.0"}
 uvicorn = {extras = ["standard"], version = "^0.22.0"}
-matplotlib = [
-    { version = "^3.5.0", markers = "python_version ~= '3.7'"},
-    { version = ">=3.6.0,<4.0.0", markers = "python_version ~= '3.11.0'"},
-]
 fastapi = ">=0.92,<1.0.0"
 fastapi = ">=0.92,<1.0.0"
 fastapi-socketio = "^0.0.10"
 fastapi-socketio = "^0.0.10"
 vbuild = "^0.8.1"
 vbuild = "^0.8.1"
 watchfiles = ">=0.18.1,<1.0.0"
 watchfiles = ">=0.18.1,<1.0.0"
 jinja2 = "^3.1.2"
 jinja2 = "^3.1.2"
 python-multipart = "^0.0.6"
 python-multipart = "^0.0.6"
-plotly = "^5.13.0"
 orjson = {version = "^3.8.6", markers = "platform_machine != 'i386' and platform_machine != 'i686'"} # orjson does not support 32bit
 orjson = {version = "^3.8.6", markers = "platform_machine != 'i386' and platform_machine != 'i686'"} # orjson does not support 32bit
-pywebview = "^4.0.2"
 importlib_metadata = { version = "^6.0.0", markers = "python_version ~= '3.7'" } # Python 3.7 has no importlib.metadata
 importlib_metadata = { version = "^6.0.0", markers = "python_version ~= '3.7'" } # Python 3.7 has no importlib.metadata
 itsdangerous = "^2.1.2"
 itsdangerous = "^2.1.2"
 aiofiles = "^23.1.0"
 aiofiles = "^23.1.0"
+netifaces = "^0.11.0"
+pywebview = { version = "^4.0.2", optional = true }
+plotly = { version = "^5.13.0", optional = true }
+matplotlib = { version = "^3.5.0", optional = true }
+
+[tool.poetry.extras]
+native = ["pywebview"]
+plotly = ["plotly"]
+matplotlib = ["matplotlib"]
 
 
 [tool.poetry.group.dev.dependencies]
 [tool.poetry.group.dev.dependencies]
 icecream = "^2.1.0"
 icecream = "^2.1.0"

+ 18 - 0
tests/test_aggrid.py

@@ -1,4 +1,5 @@
 from selenium.webdriver.common.action_chains import ActionChains
 from selenium.webdriver.common.action_chains import ActionChains
+from selenium.webdriver.common.by import By
 from selenium.webdriver.common.keys import Keys
 from selenium.webdriver.common.keys import Keys
 
 
 from nicegui import ui
 from nicegui import ui
@@ -142,3 +143,20 @@ def test_create_from_pandas(screen: Screen):
     screen.should_contain('Bob')
     screen.should_contain('Bob')
     screen.should_contain('18')
     screen.should_contain('18')
     screen.should_contain('21')
     screen.should_contain('21')
+
+
+def test_create_dynamically(screen: Screen):
+    ui.button('Create', on_click=lambda: ui.aggrid({'columnDefs': [{'field': 'name'}], 'rowData': [{'name': 'Alice'}]}))
+
+    screen.open('/')
+    screen.click('Create')
+    screen.should_contain('Alice')
+
+
+def test_api_method_after_creation(screen: Screen):
+    options = {'columnDefs': [{'field': 'name'}], 'rowData': [{'name': 'Alice'}]}
+    ui.button('Create', on_click=lambda: ui.aggrid(options).call_api_method('selectAll'))
+
+    screen.open('/')
+    screen.click('Create')
+    assert screen.selenium.find_element(By.CLASS_NAME, 'ag-row-selected')

+ 15 - 6
tests/test_chart.py

@@ -73,18 +73,19 @@ def test_removing_chart_series(screen: Screen):
     assert len(screen.selenium.find_elements(By.CSS_SELECTOR, '.highcharts-point')) == 3
     assert len(screen.selenium.find_elements(By.CSS_SELECTOR, '.highcharts-point')) == 3
 
 
 
 
-def test_extra(screen: Screen):
-    ui.chart({'chart': {'type': 'solidgauge'}}, extras=['solid-gauge'])
+def test_missing_extra(screen: Screen):
+    # NOTE: This test does not work after test_extra() has been run, because conftest won't reset libraries correctly.
+    ui.chart({'chart': {'type': 'solidgauge'}})
 
 
     screen.open('/')
     screen.open('/')
-    assert screen.selenium.find_elements(By.CSS_SELECTOR, '.highcharts-pane')
+    assert not screen.selenium.find_elements(By.CSS_SELECTOR, '.highcharts-pane')
 
 
 
 
-def test_missing_extra(screen: Screen):
-    ui.chart({'chart': {'type': 'solidgauge'}})
+def test_extra(screen: Screen):
+    ui.chart({'chart': {'type': 'solidgauge'}}, extras=['solid-gauge'])
 
 
     screen.open('/')
     screen.open('/')
-    assert not screen.selenium.find_elements(By.CSS_SELECTOR, '.highcharts-pane')
+    assert screen.selenium.find_elements(By.CSS_SELECTOR, '.highcharts-pane')
 
 
 
 
 def test_stock_chart(screen: Screen):
 def test_stock_chart(screen: Screen):
@@ -131,3 +132,11 @@ def test_stock_chart(screen: Screen):
     screen.wait(0.5)
     screen.wait(0.5)
     screen.should_not_contain('alice')
     screen.should_not_contain('alice')
     screen.should_not_contain('bob')
     screen.should_not_contain('bob')
+
+
+def test_create_dynamically(screen: Screen):
+    ui.button('Create', on_click=lambda: ui.chart({}))
+
+    screen.open('/')
+    screen.click('Create')
+    screen.should_contain('Chart title')

+ 9 - 0
tests/test_mermaid.py

@@ -53,5 +53,14 @@ def test_replace_mermaid(screen: Screen):
     screen.open('/')
     screen.open('/')
     screen.should_contain('Node_A')
     screen.should_contain('Node_A')
     screen.click('Replace')
     screen.click('Replace')
+    screen.wait(0.5)
     screen.should_contain('Node_B')
     screen.should_contain('Node_B')
     screen.should_not_contain('Node_A')
     screen.should_not_contain('Node_A')
+
+
+def test_create_dynamically(screen: Screen):
+    ui.button('Create', on_click=lambda: ui.mermaid('graph LR; Node'))
+
+    screen.open('/')
+    screen.click('Create')
+    screen.should_contain('Node')

+ 8 - 0
tests/test_plotly.py

@@ -40,3 +40,11 @@ def test_replace_plotly(screen: Screen):
     screen.click('Replace')
     screen.click('Replace')
     screen.wait(0.5)
     screen.wait(0.5)
     assert screen.find_by_tag('text').text == 'B'
     assert screen.find_by_tag('text').text == 'B'
+
+
+def test_create_dynamically(screen: Screen):
+    ui.button('Create', on_click=lambda: ui.plotly(go.Figure(go.Scatter(x=[], y=[]))))
+
+    screen.open('/')
+    screen.click('Create')
+    assert screen.find_by_tag('svg')

+ 8 - 0
tests/test_scene.py

@@ -93,3 +93,11 @@ def test_replace_scene(screen: Screen):
     screen.click('Replace scene')
     screen.click('Replace scene')
     screen.wait(0.5)
     screen.wait(0.5)
     assert screen.selenium.execute_script(f'return scene_c{scene.id}.children[4].name') == 'box'
     assert screen.selenium.execute_script(f'return scene_c{scene.id}.children[4].name') == 'box'
+
+
+def test_create_dynamically(screen: Screen):
+    ui.button('Create', on_click=lambda: ui.scene())
+
+    screen.open('/')
+    screen.click('Create')
+    assert screen.find_by_tag('canvas')

+ 14 - 0
tests/test_table.py

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

+ 7 - 3
website/documentation.py

@@ -1,6 +1,7 @@
 import uuid
 import uuid
 
 
 from nicegui import app, events, ui
 from nicegui import app, events, ui
+from nicegui.globals import optional_features
 
 
 from . import demo
 from . import demo
 from .documentation_tools import element_demo, heading, intro_demo, load_demo, subheading, text_demo
 from .documentation_tools import element_demo, heading, intro_demo, load_demo, subheading, text_demo
@@ -130,9 +131,11 @@ def create_full() -> None:
     load_demo(ui.table)
     load_demo(ui.table)
     load_demo(ui.aggrid)
     load_demo(ui.aggrid)
     load_demo(ui.chart)
     load_demo(ui.chart)
-    load_demo(ui.pyplot)
-    load_demo(ui.line_plot)
-    load_demo(ui.plotly)
+    if 'matplotlib' in optional_features:
+        load_demo(ui.pyplot)
+        load_demo(ui.line_plot)
+    if 'plotly' in optional_features:
+        load_demo(ui.plotly)
     load_demo(ui.linear_progress)
     load_demo(ui.linear_progress)
     load_demo(ui.circular_progress)
     load_demo(ui.circular_progress)
     load_demo(ui.spinner)
     load_demo(ui.spinner)
@@ -165,6 +168,7 @@ def create_full() -> None:
         ui.button('Clear', on_click=container.clear)
         ui.button('Clear', on_click=container.clear)
 
 
     load_demo(ui.expansion)
     load_demo(ui.expansion)
+    load_demo(ui.scroll_area)
     load_demo(ui.separator)
     load_demo(ui.separator)
     load_demo(ui.splitter)
     load_demo(ui.splitter)
     load_demo('tabs')
     load_demo('tabs')

+ 12 - 0
website/more_documentation/query_documentation.py

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

+ 46 - 0
website/more_documentation/scroll_area_documentation.py

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

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff