Explorar o código

Merge branch 'main' into navigating_with_proxy_prefix

Rodja Trappe %!s(int64=2) %!d(string=hai) anos
pai
achega
0b9f653df0
Modificáronse 45 ficheiros con 670 adicións e 113 borrados
  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"]
       fail-fast: false
     runs-on: ubuntu-latest
-    timeout-minutes: 15
+    timeout-minutes: 20
     steps:
       - uses: actions/checkout@v3
       - name: set up Python

+ 1 - 1
DEPENDENCIES.md

@@ -1,6 +1,6 @@
 # Included Web Dependencies
 
-- Quasar: 2.11.8
+- Quasar: 2.11.10
 - Vue: 3.2.47
 - Socket.io: 4.6.1
 - 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
-from __future__ import annotations
+from dataclasses import dataclass
 
-from typing import Optional
+import draganddrop as dnd
 
 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 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()

+ 40 - 40
main.py

@@ -63,8 +63,8 @@ def add_head_html() -> None:
 
 def add_header() -> None:
     menu_items = {
-        'Features': '/#features',
         'Installation': '/#installation',
+        'Features': '/#features',
         'Demos': '/#demos',
         'Documentation': '/documentation',
         'Examples': '/#examples',
@@ -128,6 +128,44 @@ async def index_page(client: Client):
                     '[GitHub](https://github.com/zauberzeug/nicegui).')
         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'):
         link_target('features', '-50px')
         section_heading('Features', 'Code *nicely*')
@@ -160,7 +198,7 @@ async def index_page(client: Client):
                 'live-cycle events',
                 'implicit reload on code change',
                 'straight-forward data binding',
-                'execute javascript from Python',
+                'Jupyter notebook compatibility',
             ])
             features('anchor', 'Foundation', [
                 'generic [Vue](https://vuejs.org/) to Python bridge',
@@ -169,44 +207,6 @@ async def index_page(client: Client):
                 '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'):
         link_target('demos', '-50px')
         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 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))
 
     def remove_route(self, path: str) -> None:

+ 14 - 4
nicegui/client.py

@@ -52,10 +52,12 @@ class Client:
 
     @property
     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
 
     @property
     def has_socket_connection(self) -> bool:
+        """Return True if the client is connected, False otherwise."""
         return self.environ is not None
 
     def __enter__(self):
@@ -88,7 +90,7 @@ class Client:
         }, status_code, {'Cache-Control': 'no-store', 'X-NiceGUI-Content': 'page'})
 
     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
         deadline = time.time() + timeout
         while not self.environ:
@@ -98,7 +100,7 @@ class Client:
         self.is_waiting_for_connection = False
 
     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
         while self.id in globals.clients:
             await asyncio.sleep(check_interval)
@@ -106,11 +108,12 @@ class Client:
 
     async def run_javascript(self, code: 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.
         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())
         command = {
             'code': code,
@@ -127,11 +130,18 @@ class Client:
         return self.waiting_javascript_commands.pop(request_id)
 
     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]
         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:
+        """Register a callback to be called when the client connects."""
         self.connect_handlers.append(handler)
 
     def on_disconnect(self, handler: Union[Callable, Awaitable]) -> None:
+        """Register a callback to be called when the client disconnects."""
         self.disconnect_handlers.append(handler)

+ 19 - 3
nicegui/element.py

@@ -195,21 +195,37 @@ class Element(Visibility):
             tooltip._text = text
         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.
 
         :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 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 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 args and '*' in args:
                 url = f'https://github.com/zauberzeug/nicegui/issues/644'
                 warnings.warn(DeprecationWarning(f'Event args "*" is deprecated, omit this parameter instead ({url})'))
                 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
         return self
 

+ 1 - 0
nicegui/elements/aggrid.py

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

+ 9 - 1
nicegui/elements/card.py

@@ -6,12 +6,20 @@ class Card(Element):
     def __init__(self) -> None:
         """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')
         self._classes = ['nicegui-card']
 
     def tight(self):
+        """Removes padding and gaps between nested elements."""
         self._classes.clear()
         self._style.clear()
         return self

+ 1 - 0
nicegui/elements/chart.py

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

+ 1 - 0
nicegui/elements/colors.py

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

+ 1 - 1
nicegui/elements/date.py

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

+ 1 - 0
nicegui/elements/log.py

@@ -19,6 +19,7 @@ class Log(Element):
         super().__init__('log')
         self._props['max_lines'] = max_lines
         self._props['lines'] = ''
+        self._props['key'] = self.id  # HACK: workaround for #600
         self._classes = ['nicegui-log']
         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:
             self._props['placeholder'] = placeholder
         self.validation = validation
+        self.on('blur', self.update)  # NOTE: to apply format (#736)
 
     def on_value_change(self, value: Any) -> None:
         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 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
 
         Display a 3d scene using `three.js <https://threejs.org/>`_.
@@ -70,6 +74,7 @@ class Scene(Element):
         self._props['width'] = width
         self._props['height'] = height
         self._props['grid'] = grid
+        self._props['key'] = self.id  # HACK: workaround for #600
         self.objects: Dict[str, Object3D] = {}
         self.stack: List[Union[Object3D, SceneObject]] = [SceneObject()]
         self.camera: SceneCamera = SceneCamera()

+ 4 - 0
nicegui/event_listener.py

@@ -11,6 +11,8 @@ class EventListener:
     args: List[str]
     handler: Callable
     throttle: float
+    leading_events: bool
+    trailing_events: bool
 
     def __post_init__(self) -> None:
         self.id = str(uuid.uuid4())
@@ -29,4 +31,6 @@ class EventListener:
             'keys': keys,
             'args': self.args,
             '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))
     except (ConnectionRefusedError, TimeoutError):
         return False
+    except Exception:
+        return False
     else:
         return True
     finally:

+ 12 - 7
nicegui/native_mode.py

@@ -6,7 +6,7 @@ import time
 import warnings
 from threading import Thread
 
-from . import globals
+from . import globals, helpers
 
 with warnings.catch_warnings():
     # 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
 
 
-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)
+
     webview.create_window(**window_kwargs)
     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:
         while process.is_alive():
             time.sleep(0.1)
@@ -31,17 +35,18 @@ def activate(url: str, title: str, width: int, height: int, fullscreen: bool) ->
         _thread.interrupt_main()
 
     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()
     Thread(target=check_shutdown, daemon=True).start()
 
 
 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 is better than, e.g., passing port=0 to uvicorn.
-    '''
+    """
     for port in range(start_port, end_port + 1):
         try:
             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'
         port = native_mode.find_open_port()
         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:
         host = host or '0.0.0.0'
 

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1 - 1
nicegui/static/quasar.umd.prod.js


+ 39 - 8
nicegui/templates/index.html

@@ -30,18 +30,38 @@
 
       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) {
+          // execute callback immediately and return
           callback();
           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) {
         const element = elements[id];
         const props = {
@@ -58,7 +78,7 @@
             const all = typeof e !== 'object' || !event.args;
             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});
-            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") {
               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({
         data() {
           return {
@@ -142,6 +172,7 @@
           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("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, {

+ 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.upload import Upload as upload
 from .elements.video import Video as video
+from .functions.download import download
 from .functions.html import add_body_html, add_head_html
 from .functions.javascript import run_javascript
 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.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('/')
     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]
 
     screen.wait(1.1)
+    assert events == [1, 2, 1, 1, 2]
+
     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'
 
 
+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):
 
     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):
         assert len(screen.find_all_by_tag('line')) == number_of_lines
         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('/')
     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.should_contain('This is Markdown.')
     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('Risk: High')
     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.should_contain('Trace 1')
     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.wait(0.5)
     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('AB')
     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)
 
     load_demo(ui.open)
+    load_demo(ui.download)
 
     @text_demo('Sessions', '''
         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')

+ 39 - 0
website/more_documentation/aggrid_documentation.py

@@ -89,3 +89,42 @@ def more() -> None:
                 {'name': 'Carol', 'age': 42},
             ],
         }).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 ..documentation_tools import text_demo
+
 
 def main_demo() -> None:
     with ui.card().tight() as card:
         ui.image('https://picsum.photos/id/684/640/360')
         with ui.card_section():
             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 ..documentation_tools import text_demo
+
 
 def main_demo() -> None:
     slider = ui.slider(min=0, max=100, value=50)
     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)

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio