Sfoglia il codice sorgente

Merge branch 'main' into navigating_with_proxy_prefix

Rodja Trappe 2 anni fa
parent
commit
0b9f653df0
45 ha cambiato i file con 670 aggiunte e 113 eliminazioni
  1. 1 1
      .github/workflows/test.yml
  2. 1 1
      DEPENDENCIES.md
  3. 56 0
      examples/trello_cards/draganddrop.py
  4. 17 43
      examples/trello_cards/main.py
  5. 40 40
      main.py
  6. 2 0
      nicegui/app.py
  7. 14 4
      nicegui/client.py
  8. 19 3
      nicegui/element.py
  9. 1 0
      nicegui/elements/aggrid.py
  10. 9 1
      nicegui/elements/card.py
  11. 1 0
      nicegui/elements/chart.py
  12. 1 0
      nicegui/elements/colors.py
  13. 1 1
      nicegui/elements/date.py
  14. 1 0
      nicegui/elements/log.py
  15. 1 0
      nicegui/elements/number.py
  16. 6 1
      nicegui/elements/scene.py
  17. 4 0
      nicegui/event_listener.py
  18. 14 0
      nicegui/functions/download.py
  19. 2 0
      nicegui/helpers.py
  20. 12 7
      nicegui/native_mode.py
  21. 1 1
      nicegui/run.py
  22. 1 1
      nicegui/static/quasar.umd.prod.js
  23. 39 8
      nicegui/templates/index.html
  24. 1 0
      nicegui/ui.py
  25. 17 0
      tests/test_aggrid.py
  26. 20 0
      tests/test_audio.py
  27. 17 0
      tests/test_chart.py
  28. 20 0
      tests/test_colors.py
  29. 23 0
      tests/test_download.py
  30. 44 1
      tests/test_events.py
  31. 4 0
      tests/test_helpers.py
  32. 17 0
      tests/test_interactive_image.py
  33. 17 0
      tests/test_log.py
  34. 17 0
      tests/test_markdown.py
  35. 17 0
      tests/test_mermaid.py
  36. 18 0
      tests/test_number.py
  37. 17 0
      tests/test_plotly.py
  38. 21 0
      tests/test_scene.py
  39. 17 0
      tests/test_select.py
  40. 20 0
      tests/test_video.py
  41. 11 0
      website/documentation.py
  42. 39 0
      website/more_documentation/aggrid_documentation.py
  43. 33 0
      website/more_documentation/card_documentation.py
  44. 5 0
      website/more_documentation/download_documentation.py
  45. 31 0
      website/more_documentation/slider_documentation.py

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

@@ -9,7 +9,7 @@ jobs:
         python: ["3.7", "3.8", "3.9", "3.10", "3.11"]
         python: ["3.7", "3.8", "3.9", "3.10", "3.11"]
       fail-fast: false
       fail-fast: false
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
-    timeout-minutes: 15
+    timeout-minutes: 20
     steps:
     steps:
       - uses: actions/checkout@v3
       - uses: actions/checkout@v3
       - name: set up Python
       - name: set up Python

+ 1 - 1
DEPENDENCIES.md

@@ -1,6 +1,6 @@
 # Included Web Dependencies
 # Included Web Dependencies
 
 
-- Quasar: 2.11.8
+- Quasar: 2.11.10
 - Vue: 3.2.47
 - Vue: 3.2.47
 - Socket.io: 4.6.1
 - Socket.io: 4.6.1
 - Tailwind CSS: 3.2.6
 - Tailwind CSS: 3.2.6

+ 56 - 0
examples/trello_cards/draganddrop.py

@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+from typing import Callable, Optional
+
+from typing_extensions import Protocol
+
+from nicegui import ui
+
+
+class Item(Protocol):
+    title: str
+
+
+dragged: Optional[card] = None
+
+
+class column(ui.column):
+
+    def __init__(self, name: str, on_drop: Optional[Callable[[Item, str], None]] = None) -> None:
+        super().__init__()
+        with self.classes('bg-blue-grey-2 w-60 p-4 rounded shadow-2'):
+            ui.label(name).classes('text-bold ml-1')
+        self.name = name
+        self.on('dragover.prevent', self.highlight)
+        self.on('dragleave', self.unhighlight)
+        self.on('drop', self.move_card)
+        self.on_drop = on_drop
+
+    def highlight(self) -> None:
+        self.classes(remove='bg-blue-grey-2', add='bg-blue-grey-3')
+
+    def unhighlight(self) -> None:
+        self.classes(remove='bg-blue-grey-3', add='bg-blue-grey-2')
+
+    def move_card(self) -> None:
+        global dragged
+        self.unhighlight()
+        dragged.parent_slot.parent.remove(dragged)
+        with self:
+            card(dragged.item)
+        self.on_drop(dragged.item, self.name)
+        dragged = None
+
+
+class card(ui.card):
+
+    def __init__(self, item: Item) -> None:
+        super().__init__()
+        self.item = item
+        with self.props('draggable').classes('w-full cursor-pointer bg-grey-1'):
+            ui.label(item.title)
+        self.on('dragstart', self.handle_dragstart)
+
+    def handle_dragstart(self) -> None:
+        global dragged
+        dragged = self

+ 17 - 43
examples/trello_cards/main.py

@@ -1,56 +1,30 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
-from __future__ import annotations
+from dataclasses import dataclass
 
 
-from typing import Optional
+import draganddrop as dnd
 
 
 from nicegui import ui
 from nicegui import ui
 
 
 
 
-class Column(ui.column):
+@dataclass
+class ToDo:
+    title: str
 
 
-    def __init__(self, name: str) -> None:
-        super().__init__()
-        with self.classes('bg-gray-200 w-48 p-4 rounded shadow'):
-            ui.label(name).classes('text-bold')
-        self.on('dragover.prevent', self.highlight)
-        self.on('dragleave', self.unhighlight)
-        self.on('drop', self.move_card)
 
 
-    def highlight(self) -> None:
-        self.classes(add='bg-gray-400')
-
-    def unhighlight(self) -> None:
-        self.classes(remove='bg-gray-400')
-
-    def move_card(self) -> None:
-        self.unhighlight()
-        Card.dragged.parent_slot.parent.remove(Card.dragged)
-        with self:
-            Card(Card.dragged.text)
-
-
-class Card(ui.card):
-    dragged: Optional[Card] = None
-
-    def __init__(self, text: str) -> None:
-        super().__init__()
-        self.text = text
-        with self.props('draggable').classes('w-full cursor-pointer'):
-            ui.label(self.text)
-        self.on('dragstart', self.handle_dragstart)
-
-    def handle_dragstart(self) -> None:
-        Card.dragged = self
+def handle_drop(todo: ToDo, location: str):
+    ui.notify(f'"{todo.title}" is now in {location}')
 
 
 
 
 with ui.row():
 with ui.row():
-    with Column('Next'):
-        Card('Clean up the kitchen')
-        Card('Do the laundry')
-        Card('Go to the gym')
-    with Column('Doing'):
-        Card('Make dinner')
-    with Column('Done'):
-        Card('Buy groceries')
+    with dnd.column('Next', on_drop=handle_drop):
+        dnd.card(ToDo('Simplify Layouting'))
+        dnd.card(ToDo('Provide Deployment'))
+    with dnd.column('Doing', on_drop=handle_drop):
+        dnd.card(ToDo('Improve Documentation'))
+    with dnd.column('Done', on_drop=handle_drop):
+        dnd.card(ToDo('Invent NiceGUI'))
+        dnd.card(ToDo('Test in own Projects'))
+        dnd.card(ToDo('Publish as Open Source'))
+        dnd.card(ToDo('Release Native-Mode'))
 
 
 ui.run()
 ui.run()

+ 40 - 40
main.py

@@ -63,8 +63,8 @@ def add_head_html() -> None:
 
 
 def add_header() -> None:
 def add_header() -> None:
     menu_items = {
     menu_items = {
-        'Features': '/#features',
         'Installation': '/#installation',
         'Installation': '/#installation',
+        'Features': '/#features',
         'Demos': '/#demos',
         'Demos': '/#demos',
         'Documentation': '/documentation',
         'Documentation': '/documentation',
         'Examples': '/#examples',
         'Examples': '/#examples',
@@ -128,6 +128,44 @@ async def index_page(client: Client):
                     '[GitHub](https://github.com/zauberzeug/nicegui).')
                     '[GitHub](https://github.com/zauberzeug/nicegui).')
         example_card.create()
         example_card.create()
 
 
+    with ui.column().classes('w-full text-lg p-8 lg:p-16 max-w-[1600px] mx-auto'):
+        link_target('installation', '-50px')
+        section_heading('Installation', 'Get *started*')
+        with ui.row().classes('w-full text-lg leading-tight grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8'):
+            with ui.column().classes('w-full max-w-md gap-2'):
+                ui.html('<em>1.</em>').classes('text-3xl font-bold')
+                ui.markdown('Create __main.py__').classes('text-lg')
+                with python_window(classes='w-full h-52'):
+                    ui.markdown('''```python\n
+from nicegui import ui
+
+ui.label('Hello NiceGUI!')
+
+ui.run()
+```''')
+            with ui.column().classes('w-full max-w-md gap-2'):
+                ui.html('<em>2.</em>').classes('text-3xl font-bold')
+                ui.markdown('Install and launch').classes('text-lg')
+                with bash_window(classes='w-full h-52'):
+                    ui.markdown('```bash\npip3 install nicegui\npython3 main.py\n```')
+            with ui.column().classes('w-full max-w-md gap-2'):
+                ui.html('<em>3.</em>').classes('text-3xl font-bold')
+                ui.markdown('Enjoy!').classes('text-lg')
+                with browser_window(classes='w-full h-52'):
+                    ui.label('Hello NiceGUI!')
+        with ui.expansion('...or use Docker to run your main.py').classes('w-full gap-2 bold-links arrow-links'):
+            with ui.row().classes('mt-8 w-full justify-center items-center gap-8'):
+                ui.markdown('''
+With our [multi-arch Docker image](https://hub.docker.com/repository/docker/zauberzeug/nicegui) 
+you can start the server without installing any packages.
+
+The command searches for `main.py` in in your current directory and makes the app available at http://localhost:8888.
+''').classes('max-w-xl')
+                with bash_window(classes='max-w-lg w-full h-52'):
+                    ui.markdown('```bash\n'
+                                'docker run -it --rm -p 8888:8080 \\\n -v "$PWD":/app zauberzeug/nicegui\n'
+                                '```')
+
     with ui.column().classes('w-full p-8 lg:p-16 bold-links arrow-links max-w-[1600px] mx-auto'):
     with ui.column().classes('w-full p-8 lg:p-16 bold-links arrow-links max-w-[1600px] mx-auto'):
         link_target('features', '-50px')
         link_target('features', '-50px')
         section_heading('Features', 'Code *nicely*')
         section_heading('Features', 'Code *nicely*')
@@ -160,7 +198,7 @@ async def index_page(client: Client):
                 'live-cycle events',
                 'live-cycle events',
                 'implicit reload on code change',
                 'implicit reload on code change',
                 'straight-forward data binding',
                 'straight-forward data binding',
-                'execute javascript from Python',
+                'Jupyter notebook compatibility',
             ])
             ])
             features('anchor', 'Foundation', [
             features('anchor', 'Foundation', [
                 'generic [Vue](https://vuejs.org/) to Python bridge',
                 'generic [Vue](https://vuejs.org/) to Python bridge',
@@ -169,44 +207,6 @@ async def index_page(client: Client):
                 'Python 3.7+',
                 'Python 3.7+',
             ])
             ])
 
 
-    with ui.column().classes('w-full text-lg p-8 lg:p-16 max-w-[1600px] mx-auto'):
-        link_target('installation', '-50px')
-        section_heading('Installation', 'Get *started*')
-        with ui.row().classes('w-full text-lg leading-tight grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8'):
-            with ui.column().classes('w-full max-w-md gap-2'):
-                ui.html('<em>1.</em>').classes('text-3xl font-bold')
-                ui.markdown('Create __main.py__').classes('text-lg')
-                with python_window(classes='w-full h-52'):
-                    ui.markdown('''```python\n
-from nicegui import ui
-
-ui.label('Hello NiceGUI!')
-
-ui.run()
-```''')
-            with ui.column().classes('w-full max-w-md gap-2'):
-                ui.html('<em>2.</em>').classes('text-3xl font-bold')
-                ui.markdown('Install and launch').classes('text-lg')
-                with bash_window(classes='w-full h-52'):
-                    ui.markdown('```bash\npip3 install nicegui\npython3 main.py\n```')
-            with ui.column().classes('w-full max-w-md gap-2'):
-                ui.html('<em>3.</em>').classes('text-3xl font-bold')
-                ui.markdown('Enjoy!').classes('text-lg')
-                with browser_window(classes='w-full h-52'):
-                    ui.label('Hello NiceGUI!')
-        with ui.expansion('...or use Docker to run your main.py').classes('w-full gap-2 bold-links arrow-links'):
-            with ui.row().classes('mt-8 w-full justify-center items-center gap-8'):
-                ui.markdown('''
-With our [multi-arch Docker image](https://hub.docker.com/repository/docker/zauberzeug/nicegui) 
-you can start the server without installing any packages.
-
-The command searches for `main.py` in in your current directory and makes the app available at http://localhost:8888.
-''').classes('max-w-xl')
-                with bash_window(classes='max-w-lg w-full h-52'):
-                    ui.markdown('```bash\n'
-                                'docker run -it --rm -p 8888:8080 \\\n -v "$PWD":/app zauberzeug/nicegui\n'
-                                '```')
-
     with ui.column().classes('w-full p-8 lg:p-16 max-w-[1600px] mx-auto'):
     with ui.column().classes('w-full p-8 lg:p-16 max-w-[1600px] mx-auto'):
         link_target('demos', '-50px')
         link_target('demos', '-50px')
         section_heading('Demos', 'Try *this*')
         section_heading('Demos', 'Try *this*')

+ 2 - 0
nicegui/app.py

@@ -71,6 +71,8 @@ class App(FastAPI):
         :param path: string that starts with a slash "/"
         :param path: string that starts with a slash "/"
         :param directory: folder with static files to serve under the given path
         :param directory: folder with static files to serve under the given path
         """
         """
+        if path == '/':
+            raise ValueError('''Path cannot be "/", because it would hide NiceGUI's internal "/_nicegui" route.''')
         globals.app.mount(path, StaticFiles(directory=directory))
         globals.app.mount(path, StaticFiles(directory=directory))
 
 
     def remove_route(self, path: str) -> None:
     def remove_route(self, path: str) -> None:

+ 14 - 4
nicegui/client.py

@@ -52,10 +52,12 @@ class Client:
 
 
     @property
     @property
     def ip(self) -> Optional[str]:
     def ip(self) -> Optional[str]:
+        """Return the IP address of the client, or None if the client is not connected."""
         return self.environ.get('REMOTE_ADDR') if self.environ else None
         return self.environ.get('REMOTE_ADDR') if self.environ else None
 
 
     @property
     @property
     def has_socket_connection(self) -> bool:
     def has_socket_connection(self) -> bool:
+        """Return True if the client is connected, False otherwise."""
         return self.environ is not None
         return self.environ is not None
 
 
     def __enter__(self):
     def __enter__(self):
@@ -88,7 +90,7 @@ class Client:
         }, status_code, {'Cache-Control': 'no-store', 'X-NiceGUI-Content': 'page'})
         }, status_code, {'Cache-Control': 'no-store', 'X-NiceGUI-Content': 'page'})
 
 
     async def connected(self, timeout: float = 3.0, check_interval: float = 0.1) -> None:
     async def connected(self, timeout: float = 3.0, check_interval: float = 0.1) -> None:
-        '''Blocks execution until the client is connected.'''
+        """Block execution until the client is connected."""
         self.is_waiting_for_connection = True
         self.is_waiting_for_connection = True
         deadline = time.time() + timeout
         deadline = time.time() + timeout
         while not self.environ:
         while not self.environ:
@@ -98,7 +100,7 @@ class Client:
         self.is_waiting_for_connection = False
         self.is_waiting_for_connection = False
 
 
     async def disconnected(self, check_interval: float = 0.1) -> None:
     async def disconnected(self, check_interval: float = 0.1) -> None:
-        '''Blocks execution until the client disconnects.'''
+        """Block execution until the client disconnects."""
         self.is_waiting_for_disconnect = True
         self.is_waiting_for_disconnect = True
         while self.id in globals.clients:
         while self.id in globals.clients:
             await asyncio.sleep(check_interval)
             await asyncio.sleep(check_interval)
@@ -106,11 +108,12 @@ class Client:
 
 
     async def run_javascript(self, code: str, *,
     async def run_javascript(self, code: str, *,
                              respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Optional[str]:
                              respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Optional[str]:
-        '''Allows execution of javascript on the client.
+        """Execute JavaScript on the client.
 
 
         The client connection must be established before this method is called.
         The client connection must be established before this method is called.
         You can do this by `await client.connected()` or register a callback with `client.on_connected(...)`.
         You can do this by `await client.connected()` or register a callback with `client.on_connected(...)`.
-        If respond is True, the javascript code must return a string.'''
+        If respond is True, the javascript code must return a string.
+        """
         request_id = str(uuid.uuid4())
         request_id = str(uuid.uuid4())
         command = {
         command = {
             'code': code,
             'code': code,
@@ -127,11 +130,18 @@ class Client:
         return self.waiting_javascript_commands.pop(request_id)
         return self.waiting_javascript_commands.pop(request_id)
 
 
     def open(self, target: Union[Callable, str]) -> None:
     def open(self, target: Union[Callable, str]) -> None:
+        """Open a new page in the client."""
         path = target if isinstance(target, str) else globals.page_routes[target]
         path = target if isinstance(target, str) else globals.page_routes[target]
         outbox.enqueue_message('open', path, self.id)
         outbox.enqueue_message('open', path, self.id)
 
 
+    def download(self, url: str, filename: Optional[str] = None) -> None:
+        """Download a file from the given URL."""
+        outbox.enqueue_message('download', {'url': url, 'filename': filename}, self.id)
+
     def on_connect(self, handler: Union[Callable, Awaitable]) -> None:
     def on_connect(self, handler: Union[Callable, Awaitable]) -> None:
+        """Register a callback to be called when the client connects."""
         self.connect_handlers.append(handler)
         self.connect_handlers.append(handler)
 
 
     def on_disconnect(self, handler: Union[Callable, Awaitable]) -> None:
     def on_disconnect(self, handler: Union[Callable, Awaitable]) -> None:
+        """Register a callback to be called when the client disconnects."""
         self.disconnect_handlers.append(handler)
         self.disconnect_handlers.append(handler)

+ 19 - 3
nicegui/element.py

@@ -195,21 +195,37 @@ class Element(Visibility):
             tooltip._text = text
             tooltip._text = text
         return self
         return self
 
 
-    def on(self, type: str, handler: Optional[Callable], args: Optional[List[str]] = None, *, throttle: float = 0.0) \
-            -> Self:
+    def on(self,
+           type: str,
+           handler: Optional[Callable],
+           args: Optional[List[str]] = None, *,
+           throttle: float = 0.0,
+           leading_events: bool = True,
+           trailing_events: bool = True,
+           ) -> Self:
         """Subscribe to an event.
         """Subscribe to an event.
 
 
         :param type: name of the event (e.g. "click", "mousedown", or "update:model-value")
         :param type: name of the event (e.g. "click", "mousedown", or "update:model-value")
         :param handler: callback that is called upon occurrence of the event
         :param handler: callback that is called upon occurrence of the event
         :param args: arguments included in the event message sent to the event handler (default: `None` meaning all)
         :param args: arguments included in the event message sent to the event handler (default: `None` meaning all)
         :param throttle: minimum time (in seconds) between event occurrences (default: 0.0)
         :param throttle: minimum time (in seconds) between event occurrences (default: 0.0)
+        :param leading_events: whether to trigger the event handler immediately upon the first event occurrence (default: `True`)
+        :param trailing_events: whether to trigger the event handler after the last event occurrence (default: `True`)
         """
         """
         if handler:
         if handler:
             if args and '*' in args:
             if args and '*' in args:
                 url = f'https://github.com/zauberzeug/nicegui/issues/644'
                 url = f'https://github.com/zauberzeug/nicegui/issues/644'
                 warnings.warn(DeprecationWarning(f'Event args "*" is deprecated, omit this parameter instead ({url})'))
                 warnings.warn(DeprecationWarning(f'Event args "*" is deprecated, omit this parameter instead ({url})'))
                 args = None
                 args = None
-            listener = EventListener(element_id=self.id, type=type, args=args, handler=handler, throttle=throttle)
+            listener = EventListener(
+                element_id=self.id,
+                type=type,
+                args=args,
+                handler=handler,
+                throttle=throttle,
+                leading_events=leading_events,
+                trailing_events=trailing_events,
+            )
             self._event_listeners[listener.id] = listener
             self._event_listeners[listener.id] = listener
         return self
         return self
 
 

+ 1 - 0
nicegui/elements/aggrid.py

@@ -23,6 +23,7 @@ class AgGrid(Element):
         super().__init__('aggrid')
         super().__init__('aggrid')
         self._props['options'] = options
         self._props['options'] = options
         self._props['html_columns'] = html_columns
         self._props['html_columns'] = html_columns
+        self._props['key'] = self.id  # HACK: workaround for #600
         self._classes = ['nicegui-aggrid', f'ag-theme-{theme}']
         self._classes = ['nicegui-aggrid', f'ag-theme-{theme}']
 
 
     @property
     @property

+ 9 - 1
nicegui/elements/card.py

@@ -6,12 +6,20 @@ class Card(Element):
     def __init__(self) -> None:
     def __init__(self) -> None:
         """Card
         """Card
 
 
-        Provides a container with a dropped shadow.
+        This element is based on Quasar's `QCard <https://quasar.dev/vue-components/card>`_ component.
+        It provides a container with a dropped shadow.
+
+        Note:
+        There are subtle differences between the Quasar component and this element.
+        In contrast to this element, the original QCard has no padding by default and hides outer borders of nested elements.
+        If you want the original behavior, use the `tight` method.
+        If you want the padding and borders for nested children, move the children into another container.
         """
         """
         super().__init__('q-card')
         super().__init__('q-card')
         self._classes = ['nicegui-card']
         self._classes = ['nicegui-card']
 
 
     def tight(self):
     def tight(self):
+        """Removes padding and gaps between nested elements."""
         self._classes.clear()
         self._classes.clear()
         self._style.clear()
         self._style.clear()
         return self
         return self

+ 1 - 0
nicegui/elements/chart.py

@@ -106,6 +106,7 @@ class Chart(Element):
             for dependency in js_dependencies.values()
             for dependency in js_dependencies.values()
             if dependency.optional and dependency.path.stem in extras and 'chart' in dependency.dependents
             if dependency.optional and dependency.path.stem in extras and 'chart' in dependency.dependents
         ]
         ]
+        self._props['key'] = self.id  # HACK: workaround for #600
 
 
     @property
     @property
     def options(self) -> Dict:
     def options(self) -> Dict:

+ 1 - 0
nicegui/elements/colors.py

@@ -28,4 +28,5 @@ class Colors(Element):
         self._props['negative'] = negative
         self._props['negative'] = negative
         self._props['info'] = info
         self._props['info'] = info
         self._props['warning'] = warning
         self._props['warning'] = warning
+        self._props['key'] = self.id  # HACK: workaround for #600
         self.update()
         self.update()

+ 1 - 1
nicegui/elements/date.py

@@ -4,7 +4,7 @@ from .mixins.value_element import ValueElement
 
 
 
 
 class Date(ValueElement):
 class Date(ValueElement):
-    EVENT_ARGS = ['*']
+    EVENT_ARGS = None
 
 
     def __init__(self,
     def __init__(self,
                  value: Optional[str] = None,
                  value: Optional[str] = None,

+ 1 - 0
nicegui/elements/log.py

@@ -19,6 +19,7 @@ class Log(Element):
         super().__init__('log')
         super().__init__('log')
         self._props['max_lines'] = max_lines
         self._props['max_lines'] = max_lines
         self._props['lines'] = ''
         self._props['lines'] = ''
+        self._props['key'] = self.id  # HACK: workaround for #600
         self._classes = ['nicegui-log']
         self._classes = ['nicegui-log']
         self.lines: deque[str] = deque(maxlen=max_lines)
         self.lines: deque[str] = deque(maxlen=max_lines)
 
 

+ 1 - 0
nicegui/elements/number.py

@@ -35,6 +35,7 @@ class Number(ValueElement):
         if placeholder is not None:
         if placeholder is not None:
             self._props['placeholder'] = placeholder
             self._props['placeholder'] = placeholder
         self.validation = validation
         self.validation = validation
+        self.on('blur', self.update)  # NOTE: to apply format (#736)
 
 
     def on_value_change(self, value: Any) -> None:
     def on_value_change(self, value: Any) -> None:
         super().on_value_change(value)
         super().on_value_change(value)

+ 6 - 1
nicegui/elements/scene.py

@@ -53,7 +53,11 @@ class Scene(Element):
     from .scene_objects import Text3d as text3d
     from .scene_objects import Text3d as text3d
     from .scene_objects import Texture as texture
     from .scene_objects import Texture as texture
 
 
-    def __init__(self, width: int = 400, height: int = 300, grid: bool = True, on_click: Optional[Callable] = None) -> None:
+    def __init__(self,
+                 width: int = 400,
+                 height: int = 300,
+                 grid: bool = True,
+                 on_click: Optional[Callable] = None) -> None:
         """3D Scene
         """3D Scene
 
 
         Display a 3d scene using `three.js <https://threejs.org/>`_.
         Display a 3d scene using `three.js <https://threejs.org/>`_.
@@ -70,6 +74,7 @@ class Scene(Element):
         self._props['width'] = width
         self._props['width'] = width
         self._props['height'] = height
         self._props['height'] = height
         self._props['grid'] = grid
         self._props['grid'] = grid
+        self._props['key'] = self.id  # HACK: workaround for #600
         self.objects: Dict[str, Object3D] = {}
         self.objects: Dict[str, Object3D] = {}
         self.stack: List[Union[Object3D, SceneObject]] = [SceneObject()]
         self.stack: List[Union[Object3D, SceneObject]] = [SceneObject()]
         self.camera: SceneCamera = SceneCamera()
         self.camera: SceneCamera = SceneCamera()

+ 4 - 0
nicegui/event_listener.py

@@ -11,6 +11,8 @@ class EventListener:
     args: List[str]
     args: List[str]
     handler: Callable
     handler: Callable
     throttle: float
     throttle: float
+    leading_events: bool
+    trailing_events: bool
 
 
     def __post_init__(self) -> None:
     def __post_init__(self) -> None:
         self.id = str(uuid.uuid4())
         self.id = str(uuid.uuid4())
@@ -29,4 +31,6 @@ class EventListener:
             'keys': keys,
             'keys': keys,
             'args': self.args,
             'args': self.args,
             'throttle': self.throttle,
             'throttle': self.throttle,
+            'leading_events': self.leading_events,
+            'trailing_events': self.trailing_events,
         }
         }

+ 14 - 0
nicegui/functions/download.py

@@ -0,0 +1,14 @@
+from typing import Optional
+
+from .. import globals
+
+
+def download(url: str, filename: Optional[str] = None) -> None:
+    """Download
+
+    Function to trigger the download of a file.
+
+    :param url: target URL of the file to download
+    :param filename: name of the file to download (default: name of the file on the server)
+    """
+    globals.get_client().download(url, filename)

+ 2 - 0
nicegui/helpers.py

@@ -46,6 +46,8 @@ def is_port_open(host: str, port: int) -> bool:
         sock.connect((host, port))
         sock.connect((host, port))
     except (ConnectionRefusedError, TimeoutError):
     except (ConnectionRefusedError, TimeoutError):
         return False
         return False
+    except Exception:
+        return False
     else:
     else:
         return True
         return True
     finally:
     finally:

+ 12 - 7
nicegui/native_mode.py

@@ -6,7 +6,7 @@ import time
 import warnings
 import warnings
 from threading import Thread
 from threading import Thread
 
 
-from . import globals
+from . import globals, helpers
 
 
 with warnings.catch_warnings():
 with warnings.catch_warnings():
     # webview depends on bottle which uses the deprecated CGI function (https://github.com/bottlepy/bottle/issues/1403)
     # webview depends on bottle which uses the deprecated CGI function (https://github.com/bottlepy/bottle/issues/1403)
@@ -14,14 +14,18 @@ with warnings.catch_warnings():
     import webview
     import webview
 
 
 
 
-def open_window(url: str, title: str, width: int, height: int, fullscreen: bool) -> None:
-    window_kwargs = dict(url=url, title=title, width=width, height=height, fullscreen=fullscreen)
+def open_window(host: str, port: int, title: str, width: int, height: int, fullscreen: bool) -> None:
+    while not helpers.is_port_open(host, port):
+        time.sleep(0.1)
+
+    window_kwargs = dict(url=f'http://{host}:{port}', title=title, width=width, height=height, fullscreen=fullscreen)
     window_kwargs.update(globals.app.native.window_args)
     window_kwargs.update(globals.app.native.window_args)
+
     webview.create_window(**window_kwargs)
     webview.create_window(**window_kwargs)
     webview.start(storage_path=tempfile.mkdtemp(), **globals.app.native.start_args)
     webview.start(storage_path=tempfile.mkdtemp(), **globals.app.native.start_args)
 
 
 
 
-def activate(url: str, title: str, width: int, height: int, fullscreen: bool) -> None:
+def activate(host: str, port: int, title: str, width: int, height: int, fullscreen: bool) -> None:
     def check_shutdown() -> None:
     def check_shutdown() -> None:
         while process.is_alive():
         while process.is_alive():
             time.sleep(0.1)
             time.sleep(0.1)
@@ -31,17 +35,18 @@ def activate(url: str, title: str, width: int, height: int, fullscreen: bool) ->
         _thread.interrupt_main()
         _thread.interrupt_main()
 
 
     multiprocessing.freeze_support()
     multiprocessing.freeze_support()
-    process = multiprocessing.Process(target=open_window, args=(url, title, width, height, fullscreen), daemon=False)
+    process = multiprocessing.Process(target=open_window, args=(host, port, title, width, height, fullscreen),
+                                      daemon=False)
     process.start()
     process.start()
     Thread(target=check_shutdown, daemon=True).start()
     Thread(target=check_shutdown, daemon=True).start()
 
 
 
 
 def find_open_port(start_port: int = 8000, end_port: int = 8999) -> int:
 def find_open_port(start_port: int = 8000, end_port: int = 8999) -> int:
-    '''Reliably find an open port in a given range.
+    """Reliably find an open port in a given range.
 
 
     This function will actually try to open the port to ensure no firewall blocks it.
     This function will actually try to open the port to ensure no firewall blocks it.
     This is better than, e.g., passing port=0 to uvicorn.
     This is better than, e.g., passing port=0 to uvicorn.
-    '''
+    """
     for port in range(start_port, end_port + 1):
     for port in range(start_port, end_port + 1):
         try:
         try:
             with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
             with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:

+ 1 - 1
nicegui/run.py

@@ -84,7 +84,7 @@ def run(*,
         host = host or '127.0.0.1'
         host = host or '127.0.0.1'
         port = native_mode.find_open_port()
         port = native_mode.find_open_port()
         width, height = window_size or (800, 600)
         width, height = window_size or (800, 600)
-        native_mode.activate(f'http://{host}:{port}', title, width, height, fullscreen)
+        native_mode.activate(host, port, title, width, height, fullscreen)
     else:
     else:
         host = host or '0.0.0.0'
         host = host or '0.0.0.0'
 
 

File diff suppressed because it is too large
+ 1 - 1
nicegui/static/quasar.umd.prod.js


+ 39 - 8
nicegui/templates/index.html

@@ -30,18 +30,38 @@
 
 
       const elements = {{ elements | safe }};
       const elements = {{ elements | safe }};
 
 
-      const throttles = new Set();
-      function throttle(callback, time, id) {
+      const waitingCallbacks = new Map();
+      function throttle(callback, time, leading, trailing, id) {
         if (time <= 0) {
         if (time <= 0) {
+          // execute callback immediately and return
           callback();
           callback();
           return;
           return;
         }
         }
-        if (throttles.has(id)) return;
-        throttles.add(id);
-        callback();
-        setTimeout(() => throttles.delete(id), 1000 * time);
+        if (waitingCallbacks.has(id)) {
+          if (trailing) {
+            // update trailing callback
+            waitingCallbacks.set(id, callback);
+          }
+        } else {
+          if (leading) {
+            // execute leading callback and set timeout to block more leading callbacks
+            callback();
+            waitingCallbacks.set(id, null);
+          }
+          else if (trailing) {
+            // set trailing callback and set timeout to execute it
+            waitingCallbacks.set(id, callback);
+          }
+          if (leading || trailing) {
+            // set timeout to remove block and to execute trailing callback
+            setTimeout(() => {
+              const trailingCallback = waitingCallbacks.get(id);
+              if (trailingCallback) trailingCallback();
+              waitingCallbacks.delete(id)
+            }, 1000 * time);
+          }
+        }
       }
       }
-
       function renderRecursively(elements, id) {
       function renderRecursively(elements, id) {
         const element = elements[id];
         const element = elements[id];
         const props = {
         const props = {
@@ -58,7 +78,7 @@
             const all = typeof e !== 'object' || !event.args;
             const all = typeof e !== 'object' || !event.args;
             const args = all ? e : Object.fromEntries(event.args.map(a => [a, e[a]]));
             const args = all ? e : Object.fromEntries(event.args.map(a => [a, e[a]]));
             const emitter = () => window.socket.emit("event", {id: element.id, listener_id: event.listener_id, args});
             const emitter = () => window.socket.emit("event", {id: element.id, listener_id: event.listener_id, args});
-            throttle(emitter, event.throttle, event.listener_id);
+            throttle(emitter, event.throttle, event.leading_events, event.trailing_events, event.listener_id);
             if (element.props["loopback"] === False && event.type == "update:model-value") {
             if (element.props["loopback"] === False && event.type == "update:model-value") {
               element.props["model-value"] = args;
               element.props["model-value"] = args;
             }
             }
@@ -110,6 +130,16 @@
         });
         });
       }
       }
 
 
+      function download(url, filename) {
+        const anchor = document.createElement("a");
+        anchor.href = url;
+        anchor.target = "_blank";
+        anchor.download = filename || "";
+        document.body.appendChild(anchor);
+        anchor.click();
+        document.body.removeChild(anchor);
+      }
+
       const app = Vue.createApp({
       const app = Vue.createApp({
         data() {
         data() {
           return {
           return {
@@ -142,6 +172,7 @@
           window.socket.on("run_method", (msg) => getElement(msg.id)?.[msg.name](...msg.args));
           window.socket.on("run_method", (msg) => getElement(msg.id)?.[msg.name](...msg.args));
           window.socket.on("run_javascript", (msg) => runJavascript(msg['code'], msg['request_id']));
           window.socket.on("run_javascript", (msg) => runJavascript(msg['code'], msg['request_id']));
           window.socket.on("open", (msg) => (location.href = msg.startsWith('/') ? "{{ prefix | safe }}" + msg : msg));
           window.socket.on("open", (msg) => (location.href = msg.startsWith('/') ? "{{ prefix | safe }}" + msg : msg));
+          window.socket.on("download", (msg) => download(msg.url, msg.filename));
           window.socket.on("notify", (msg) => Quasar.Notify.create(msg));
           window.socket.on("notify", (msg) => Quasar.Notify.create(msg));
         },
         },
       }).use(Quasar, {
       }).use(Quasar, {

+ 1 - 0
nicegui/ui.py

@@ -61,6 +61,7 @@ from .elements.tooltip import Tooltip as tooltip
 from .elements.tree import Tree as tree
 from .elements.tree import Tree as tree
 from .elements.upload import Upload as upload
 from .elements.upload import Upload as upload
 from .elements.video import Video as video
 from .elements.video import Video as video
+from .functions.download import download
 from .functions.html import add_body_html, add_head_html
 from .functions.html import add_body_html, add_head_html
 from .functions.javascript import run_javascript
 from .functions.javascript import run_javascript
 from .functions.notify import notify
 from .functions.notify import notify

+ 17 - 0
tests/test_aggrid.py

@@ -111,3 +111,20 @@ def test_get_selected_rows(screen: Screen):
 
 
     screen.click('Get selected row')
     screen.click('Get selected row')
     screen.should_contain("{'name': 'Alice'}")
     screen.should_contain("{'name': 'Alice'}")
+
+
+def test_replace_aggrid(screen: Screen):
+    with ui.row().classes('w-full') as container:
+        ui.aggrid({'columnDefs': [{'field': 'name'}], 'rowData': [{'name': 'Alice'}]})
+
+    def replace():
+        container.clear()
+        with container:
+            ui.aggrid({'columnDefs': [{'field': 'name'}], 'rowData': [{'name': 'Bob'}]})
+    ui.button('Replace', on_click=replace)
+
+    screen.open('/')
+    screen.should_contain('Alice')
+    screen.click('Replace')
+    screen.should_contain('Bob')
+    screen.should_not_contain('Alice')

+ 20 - 0
tests/test_audio.py

@@ -0,0 +1,20 @@
+from nicegui import ui
+
+from .screen import Screen
+
+
+def test_replace_audio(screen: Screen):
+    with ui.row() as container:
+        ui.audio('https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3')
+
+    def replace():
+        container.clear()
+        with container:
+            ui.audio('https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3')
+    ui.button('Replace', on_click=replace)
+
+    screen.open('/')
+    assert screen.find_by_tag('audio').get_attribute('src').endswith('SoundHelix-Song-1.mp3')
+    screen.click('Replace')
+    screen.wait(0.5)
+    assert screen.find_by_tag('audio').get_attribute('src').endswith('SoundHelix-Song-2.mp3')

+ 17 - 0
tests/test_chart.py

@@ -92,3 +92,20 @@ def test_stock_chart(screen: Screen):
 
 
     screen.open('/')
     screen.open('/')
     assert screen.selenium.find_elements(By.CSS_SELECTOR, '.highcharts-range-selector-buttons')
     assert screen.selenium.find_elements(By.CSS_SELECTOR, '.highcharts-range-selector-buttons')
+
+
+def test_replace_chart(screen: Screen):
+    with ui.row() as container:
+        ui.chart({'series': [{'name': 'A'}]})
+
+    def replace():
+        container.clear()
+        with container:
+            ui.chart({'series': [{'name': 'B'}]})
+    ui.button('Replace', on_click=replace)
+
+    screen.open('/')
+    screen.should_contain('A')
+    screen.click('Replace')
+    screen.should_contain('B')
+    screen.should_not_contain('A')

+ 20 - 0
tests/test_colors.py

@@ -0,0 +1,20 @@
+from nicegui import ui
+
+from .screen import Screen
+
+
+def test_replace_colors(screen: Screen):
+    with ui.row() as container:
+        ui.colors(primary='blue')
+
+    def replace():
+        container.clear()
+        with container:
+            ui.colors(primary='red')
+    ui.button('Replace', on_click=replace)
+
+    screen.open('/')
+    assert screen.find_by_tag('button').value_of_css_property('background-color') == 'rgba(0, 0, 255, 1)'
+    screen.click('Replace')
+    screen.wait(0.5)
+    assert screen.find_by_tag('button').value_of_css_property('background-color') == 'rgba(255, 0, 0, 1)'

+ 23 - 0
tests/test_download.py

@@ -0,0 +1,23 @@
+from fastapi import HTTPException
+
+from nicegui import app, ui
+
+from .screen import PORT, Screen
+
+
+def test_download(screen: Screen):
+    success = False
+
+    @app.get('/static/test.py')
+    def test():
+        nonlocal success
+        success = True
+        raise HTTPException(404, 'Not found')
+
+    ui.button('Download', on_click=lambda: ui.download('static/test.py'))
+
+    screen.open('/')
+    screen.click('Download')
+    screen.wait(0.5)
+    assert success
+    screen.assert_py_logger('WARNING', f'http://localhost:{PORT}/static/test.py not found')

+ 44 - 1
tests/test_events.py

@@ -103,7 +103,50 @@ def test_throttling(screen: Screen):
     assert events == [1, 2, 1, 1]
     assert events == [1, 2, 1, 1]
 
 
     screen.wait(1.1)
     screen.wait(1.1)
+    assert events == [1, 2, 1, 1, 2]
+
     screen.click('Test')
     screen.click('Test')
     screen.click('Test')
     screen.click('Test')
     screen.click('Test')
     screen.click('Test')
-    assert events == [1, 2, 1, 1, 1, 2, 1, 1]
+    assert events == [1, 2, 1, 1, 2, 1, 2, 1, 1]
+
+
+def test_throttling_variants(screen: Screen):
+    events = []
+    value = 0
+    ui.button('Both').on('click', lambda: events.append(value), throttle=1)
+    ui.button('Leading').on('click', lambda: events.append(value), throttle=1, trailing_events=False)
+    ui.button('Trailing').on('click', lambda: events.append(value), throttle=1, leading_events=False)
+
+    screen.open('/')
+    value = 1
+    screen.click('Both')
+    value = 2
+    screen.click('Both')
+    value = 3
+    screen.click('Both')
+    assert events == [1]
+    screen.wait(1.1)
+    assert events == [1, 3]
+
+    events = []
+    value = 1
+    screen.click('Leading')
+    value = 2
+    screen.click('Leading')
+    value = 3
+    screen.click('Leading')
+    assert events == [1]
+    screen.wait(1.1)
+    assert events == [1]
+
+    events = []
+    value = 1
+    screen.click('Trailing')
+    value = 2
+    screen.click('Trailing')
+    value = 3
+    screen.click('Trailing')
+    assert events == []
+    screen.wait(1.1)
+    assert events == [3]

+ 4 - 0
tests/test_helpers.py

@@ -19,6 +19,10 @@ def test_is_port_open():
         assert helpers.is_port_open(host, port), 'after opening the socket, the port should be detected'
         assert helpers.is_port_open(host, port), 'after opening the socket, the port should be detected'
 
 
 
 
+def test_is_port_open_on_bad_ip():
+    assert not helpers.is_port_open('1.2', 0), 'should not be able to connect to a bad IP'
+
+
 def test_schedule_browser(monkeypatch):
 def test_schedule_browser(monkeypatch):
 
 
     called_with_url = None
     called_with_url = None

+ 17 - 0
tests/test_interactive_image.py

@@ -40,3 +40,20 @@ def test_with_cross(screen: Screen, cross: bool, number_of_lines: int):
     with screen.implicitly_wait(0.5):
     with screen.implicitly_wait(0.5):
         assert len(screen.find_all_by_tag('line')) == number_of_lines
         assert len(screen.find_all_by_tag('line')) == number_of_lines
         assert len(screen.find_all_by_tag('circle')) == 1
         assert len(screen.find_all_by_tag('circle')) == 1
+
+
+def test_replace_interactive_image(screen: Screen):
+    with ui.row() as container:
+        ui.interactive_image('https://picsum.photos/id/29/640/360')
+
+    def replace():
+        container.clear()
+        with container:
+            ui.interactive_image('https://picsum.photos/id/30/640/360')
+    ui.button('Replace', on_click=replace)
+
+    screen.open('/')
+    assert screen.find_by_tag('img').get_attribute('src').endswith('id/29/640/360')
+    screen.click('Replace')
+    screen.wait(0.5)
+    assert screen.find_by_tag('img').get_attribute('src').endswith('id/30/640/360')

+ 17 - 0
tests/test_log.py

@@ -26,3 +26,20 @@ def test_log_with_newlines(screen: Screen):
 
 
     screen.open('/')
     screen.open('/')
     assert screen.find_by_id(log.id).text == 'B\nC\nD'
     assert screen.find_by_id(log.id).text == 'B\nC\nD'
+
+
+def test_replace_log(screen: Screen):
+    with ui.row() as container:
+        ui.log().push('A')
+
+    def replace():
+        container.clear()
+        with container:
+            ui.log().push('B')
+    ui.button('Replace', on_click=replace)
+
+    screen.open('/')
+    screen.should_contain('A')
+    screen.click('Replace')
+    screen.should_contain('B')
+    screen.should_not_contain('A')

+ 17 - 0
tests/test_markdown.py

@@ -53,3 +53,20 @@ def test_strip_indentation(screen: Screen):
     screen.open('/')
     screen.open('/')
     screen.should_contain('This is Markdown.')
     screen.should_contain('This is Markdown.')
     screen.should_not_contain('**This is Markdown.**')  # NOTE: '**' are translated to formatting and not visible
     screen.should_not_contain('**This is Markdown.**')  # NOTE: '**' are translated to formatting and not visible
+
+
+def test_replace_markdown(screen: Screen):
+    with ui.row() as container:
+        ui.markdown('A')
+
+    def replace():
+        container.clear()
+        with container:
+            ui.markdown('B')
+    ui.button('Replace', on_click=replace)
+
+    screen.open('/')
+    screen.should_contain('A')
+    screen.click('Replace')
+    screen.should_contain('B')
+    screen.should_not_contain('A')

+ 17 - 0
tests/test_mermaid.py

@@ -38,3 +38,20 @@ def test_mermaid_with_line_breaks(screen: Screen):
     screen.should_contain('Text: some test text')
     screen.should_contain('Text: some test text')
     screen.should_contain('Risk: High')
     screen.should_contain('Risk: High')
     screen.should_contain('Verification: Test')
     screen.should_contain('Verification: Test')
+
+
+def test_replace_mermaid(screen: Screen):
+    with ui.row() as container:
+        ui.mermaid('graph LR; Node_A')
+
+    def replace():
+        container.clear()
+        with container:
+            ui.mermaid('graph LR; Node_B')
+    ui.button('Replace', on_click=replace)
+
+    screen.open('/')
+    screen.should_contain('Node_A')
+    screen.click('Replace')
+    screen.should_contain('Node_B')
+    screen.should_not_contain('Node_A')

+ 18 - 0
tests/test_number.py

@@ -0,0 +1,18 @@
+from selenium.webdriver.common.by import By
+
+from nicegui import ui
+
+from .screen import Screen
+
+
+def test_apply_format_on_blur(screen: Screen):
+    ui.number('Number', format='%.4f', value=3.14159)
+    ui.button('Button')
+
+    screen.open('/')
+    screen.should_contain_input('3.1416')
+
+    element = screen.selenium.find_element(By.XPATH, '//*[@aria-label="Number"]')
+    element.send_keys('789')
+    screen.click('Button')
+    screen.should_contain_input('3.1417')

+ 17 - 0
tests/test_plotly.py

@@ -23,3 +23,20 @@ def test_plotly(screen: Screen):
     screen.click('Add trace')
     screen.click('Add trace')
     screen.should_contain('Trace 1')
     screen.should_contain('Trace 1')
     screen.should_contain('Trace 2')
     screen.should_contain('Trace 2')
+
+
+def test_replace_plotly(screen: Screen):
+    with ui.row() as container:
+        ui.plotly(go.Figure(go.Scatter(x=[1], y=[1], text=['A'], mode='text')))
+
+    def replace():
+        container.clear()
+        with container:
+            ui.plotly(go.Figure(go.Scatter(x=[1], y=[1], text=['B'], mode='text')))
+    ui.button('Replace', on_click=replace)
+
+    screen.open('/')
+    assert screen.find_by_tag('text').text == 'A'
+    screen.click('Replace')
+    screen.wait(0.5)
+    assert screen.find_by_tag('text').text == 'B'

+ 21 - 0
tests/test_scene.py

@@ -72,3 +72,24 @@ def test_deleting_group(screen: Screen):
     screen.click('Delete group')
     screen.click('Delete group')
     screen.wait(0.5)
     screen.wait(0.5)
     assert len(scene.objects) == 0
     assert len(scene.objects) == 0
+
+
+def test_replace_scene(screen: Screen):
+    with ui.row() as container:
+        with ui.scene() as scene:
+            scene.sphere().with_name('sphere')
+
+    def replace():
+        container.clear()
+        with container:
+            nonlocal scene
+            with ui.scene() as scene:
+                scene.box().with_name('box')
+    ui.button('Replace scene', on_click=replace)
+
+    screen.open('/')
+    screen.wait(0.5)
+    assert screen.selenium.execute_script(f'return scene_{scene.id}.children[4].name') == 'sphere'
+    screen.click('Replace scene')
+    screen.wait(0.5)
+    assert screen.selenium.execute_script(f'return scene_{scene.id}.children[4].name') == 'box'

+ 17 - 0
tests/test_select.py

@@ -31,3 +31,20 @@ def test_select_with_input(screen: Screen):
     screen.should_contain('A')
     screen.should_contain('A')
     screen.should_contain('AB')
     screen.should_contain('AB')
     screen.should_not_contain('XYZ')
     screen.should_not_contain('XYZ')
+
+
+def test_replace_select(screen: Screen):
+    with ui.row() as container:
+        ui.select(['A'], value='A')
+
+    def replace():
+        container.clear()
+        with container:
+            ui.select(['B'], value='B')
+    ui.button('Replace', on_click=replace)
+
+    screen.open('/')
+    screen.should_contain('A')
+    screen.click('Replace')
+    screen.should_contain('B')
+    screen.should_not_contain('A')

+ 20 - 0
tests/test_video.py

@@ -0,0 +1,20 @@
+from nicegui import ui
+
+from .screen import Screen
+
+
+def test_replace_video(screen: Screen):
+    with ui.row() as container:
+        ui.video('https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4')
+
+    def replace():
+        container.clear()
+        with container:
+            ui.video('https://storage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4')
+    ui.button('Replace', on_click=replace)
+
+    screen.open('/')
+    assert screen.find_by_tag('video').get_attribute('src').endswith('BigBuckBunny.mp4')
+    screen.click('Replace')
+    screen.wait(0.5)
+    assert screen.find_by_tag('video').get_attribute('src').endswith('ElephantsDream.mp4')

+ 11 - 0
website/documentation.py

@@ -410,6 +410,7 @@ def create_full() -> None:
         ui.link('show page with fancy layout', page_layout)
         ui.link('show page with fancy layout', page_layout)
 
 
     load_demo(ui.open)
     load_demo(ui.open)
+    load_demo(ui.download)
 
 
     @text_demo('Sessions', '''
     @text_demo('Sessions', '''
         The optional `request` argument provides insights about the client's URL parameters etc.
         The optional `request` argument provides insights about the client's URL parameters etc.
@@ -766,4 +767,14 @@ def create_full() -> None:
             ```
             ```
         ''')
         ''')
 
 
+    ui.markdown('''
+        **Note:**
+        If you're getting an error "TypeError: a bytes-like object is required, not 'str'", try adding the following lines to the top of your `main.py` file:
+        ```py
+        import sys
+        sys.stdout = open('logs.txt', 'w')
+        ```
+        See <https://github.com/zauberzeug/nicegui/issues/681> for more information.
+    ''')
+
     ui.element('div').classes('h-32')
     ui.element('div').classes('h-32')

+ 39 - 0
website/more_documentation/aggrid_documentation.py

@@ -89,3 +89,42 @@ def more() -> None:
                 {'name': 'Carol', 'age': 42},
                 {'name': 'Carol', 'age': 42},
             ],
             ],
         }).classes('max-h-40')
         }).classes('max-h-40')
+
+    @text_demo('AG Grid with Conditional Cell Formatting', '''
+        This demo shows how to use [cellClassRules](https://www.ag-grid.com/javascript-grid-cell-styles/#cell-class-rules)
+        to conditionally format cells based on their values.
+        Since it is currently not possible to use the `cellClassRules` option in the `columnDefs` option,
+        we use the `run_javascript` method to set the `cellClassRules` option after the grid has been created.
+        The timer is used to delay the execution of the javascript code until the grid has been created.
+        You can also use `app.on_connect` instead.
+    ''')
+    def aggrid_with_conditional_cell_formatting():
+        ui.html('''
+            <style>
+            .cell-fail { background-color: #f6695e; }
+            .cell-pass { background-color: #70bf73; }
+           </style>
+        ''')
+
+        grid = ui.aggrid({
+            'columnDefs': [
+                {'headerName': 'Name', 'field': 'name'},
+                {'headerName': 'Age', 'field': 'age'},
+            ],
+            'rowData': [
+                {'name': 'Alice', 'age': 18},
+                {'name': 'Bob', 'age': 21},
+                {'name': 'Carol', 'age': 42},
+            ],
+        })
+
+        async def format() -> None:
+            await ui.run_javascript(f'''
+                getElement({grid.id}).gridOptions.columnApi.getColumn("age").getColDef().cellClassRules = {{
+                    "cell-fail": x => x.value < 21,
+                    "cell-pass": x => x.value >= 21,
+                }};
+                getElement({grid.id}).gridOptions.api.refreshCells();
+            ''', respond=False)
+
+        ui.timer(0, format, once=True)

+ 33 - 0
website/more_documentation/card_documentation.py

@@ -1,8 +1,41 @@
 from nicegui import ui
 from nicegui import ui
 
 
+from ..documentation_tools import text_demo
+
 
 
 def main_demo() -> None:
 def main_demo() -> None:
     with ui.card().tight() as card:
     with ui.card().tight() as card:
         ui.image('https://picsum.photos/id/684/640/360')
         ui.image('https://picsum.photos/id/684/640/360')
         with ui.card_section():
         with ui.card_section():
             ui.label('Lorem ipsum dolor sit amet, consectetur adipiscing elit, ...')
             ui.label('Lorem ipsum dolor sit amet, consectetur adipiscing elit, ...')
+
+
+def more() -> None:
+    @text_demo('The issue with nested borders', '''
+        The following example shows a table nested in a card.
+        Cards have a default padding in NiceGUI, so the table is not flush with the card's border.
+        The table has the `flat` and `bordered` props set, so it should have a border.
+        However, due to the way QCard is designed, the border is not visible (first card).
+        There are two ways to fix this:
+
+        - To get the original QCard behavior, use the `tight` method (second card).
+            It removes the padding and the table border collapses with the card border.
+        
+        - To preserve the padding _and_ the table border, move the table into another container like a `ui.row` (third card).
+
+        See https://github.com/zauberzeug/nicegui/issues/726 for more information.
+    ''')
+    def custom_context_menu() -> None:
+        columns = [{'name': 'age', 'label': 'Age', 'field': 'age'}]
+        rows = [{'age': '16'}, {'age': '18'}, {'age': '21'}]
+
+        with ui.row():
+            with ui.card():
+                ui.table(columns, rows).props('flat bordered')
+
+            with ui.card().tight():
+                ui.table(columns, rows).props('flat bordered')
+
+            with ui.card():
+                with ui.row():
+                    ui.table(columns, rows).props('flat bordered')

+ 5 - 0
website/more_documentation/download_documentation.py

@@ -0,0 +1,5 @@
+from nicegui import ui
+
+
+def main_demo() -> None:
+    ui.button('NiceGUI Logo', on_click=lambda: ui.download('https://nicegui.io/logo.png'))

+ 31 - 0
website/more_documentation/slider_documentation.py

@@ -1,6 +1,37 @@
 from nicegui import ui
 from nicegui import ui
 
 
+from ..documentation_tools import text_demo
+
 
 
 def main_demo() -> None:
 def main_demo() -> None:
     slider = ui.slider(min=0, max=100, value=50)
     slider = ui.slider(min=0, max=100, value=50)
     ui.label().bind_text_from(slider, 'value')
     ui.label().bind_text_from(slider, 'value')
+
+
+def more() -> None:
+    @text_demo('Throttle events with leading and trailing options', '''
+        By default the value change event of a slider is throttled to 0.05 seconds.
+        This means that if you move the slider quickly, the value will only be updated every 0.05 seconds.
+
+        By default both "leading" and "trailing" events are activated.
+        This means that the very first event is triggered immediately, and the last event is triggered after the throttle time.
+
+        This demo shows how disabling either of these options changes the behavior.
+        To see the effect more clearly, the throttle time is set to 1 second.
+        The first slider shows the default behavior, the second one only sends leading events, and the third only sends trailing events.
+    ''')
+    def throttle_events_with_leading_and_trailing_options():
+        ui.label('default')
+        ui.slider(min=0, max=10, step=0.1, value=5).props('label-always') \
+            .on('update:model-value', lambda msg: ui.notify(f'{msg["args"]}'),
+                throttle=1.0)
+
+        ui.label('leading events only')
+        ui.slider(min=0, max=10, step=0.1, value=5).props('label-always') \
+            .on('update:model-value', lambda msg: ui.notify(f'{msg["args"]}'),
+                throttle=1.0, trailing_events=False)
+
+        ui.label('trailing events only')
+        ui.slider(min=0, max=10, step=0.1, value=5).props('label-always') \
+            .on('update:model-value', lambda msg: ui.notify(f'{msg["args"]}'),
+                throttle=1.0, leading_events=False)

Some files were not shown because too many files changed in this diff