1
0
Эх сурвалжийг харах

Merge branch 'main' into v1.3

# Conflicts:
#	nicegui/events.py
#	poetry.lock
#	pyproject.toml
Falko Schindler 1 жил өмнө
parent
commit
c8e9bb5325

+ 1 - 1
examples/lightbox/main.py

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

+ 0 - 1
nicegui.code-workspace

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

+ 1 - 1
nicegui/client.py

@@ -35,7 +35,7 @@ class Client:
         self.environ: Optional[Dict[str, Any]] = None
         self.shared = shared
 
-        with Element('q-layout', _client=self).props('view="HHH LpR FFF"').classes('nicegui-layout') as self.layout:
+        with Element('q-layout', _client=self).props('view="hhh lpr fff"').classes('nicegui-layout') as self.layout:
             with Element('q-page-container') as self.page_container:
                 with Element('q-page'):
                     self.content = Element('div').classes('nicegui-content')

+ 4 - 2
nicegui/elements/knob.py

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

+ 2 - 0
nicegui/elements/query.py

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

+ 70 - 0
nicegui/elements/scroll_area.py

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

+ 1 - 0
nicegui/elements/table.py

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

+ 14 - 4
nicegui/events.py

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

+ 2 - 2
nicegui/favicon.py

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

+ 12 - 1
nicegui/helpers.py

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

+ 2 - 8
nicegui/nicegui.py

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

+ 4 - 0
nicegui/static/nicegui.css

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

+ 2 - 0
nicegui/ui.py

@@ -46,6 +46,7 @@ __all__ = [
     'radio',
     'row',
     'scene',
+    'scroll_area',
     'select',
     'separator',
     'slider',
@@ -132,6 +133,7 @@ from .elements.query import query
 from .elements.radio import Radio as radio
 from .elements.row import Row as row
 from .elements.scene import Scene as scene
+from .elements.scroll_area import ScrollArea as scroll_area
 from .elements.select import Select as select
 from .elements.separator import Separator as separator
 from .elements.slider import Slider as slider

+ 40 - 0
poetry.lock

@@ -951,6 +951,46 @@ pillow = ">=6.2.0"
 pyparsing = ">=2.3.1,<3.1"
 python-dateutil = ">=2.7"
 
+[[package]]
+name = "netifaces"
+version = "0.11.0"
+description = "Portable network interface information."
+category = "main"
+optional = false
+python-versions = "*"
+files = [
+    {file = "netifaces-0.11.0-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eb4813b77d5df99903af4757ce980a98c4d702bbcb81f32a0b305a1537bdf0b1"},
+    {file = "netifaces-0.11.0-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:5f9ca13babe4d845e400921973f6165a4c2f9f3379c7abfc7478160e25d196a4"},
+    {file = "netifaces-0.11.0-cp27-cp27m-win32.whl", hash = "sha256:7dbb71ea26d304e78ccccf6faccef71bb27ea35e259fb883cfd7fd7b4f17ecb1"},
+    {file = "netifaces-0.11.0-cp27-cp27m-win_amd64.whl", hash = "sha256:0f6133ac02521270d9f7c490f0c8c60638ff4aec8338efeff10a1b51506abe85"},
+    {file = "netifaces-0.11.0-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:08e3f102a59f9eaef70948340aeb6c89bd09734e0dca0f3b82720305729f63ea"},
+    {file = "netifaces-0.11.0-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c03fb2d4ef4e393f2e6ffc6376410a22a3544f164b336b3a355226653e5efd89"},
+    {file = "netifaces-0.11.0-cp34-cp34m-win32.whl", hash = "sha256:73ff21559675150d31deea8f1f8d7e9a9a7e4688732a94d71327082f517fc6b4"},
+    {file = "netifaces-0.11.0-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:815eafdf8b8f2e61370afc6add6194bd5a7252ae44c667e96c4c1ecf418811e4"},
+    {file = "netifaces-0.11.0-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:50721858c935a76b83dd0dd1ab472cad0a3ef540a1408057624604002fcfb45b"},
+    {file = "netifaces-0.11.0-cp35-cp35m-win32.whl", hash = "sha256:c9a3a47cd3aaeb71e93e681d9816c56406ed755b9442e981b07e3618fb71d2ac"},
+    {file = "netifaces-0.11.0-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:aab1dbfdc55086c789f0eb37affccf47b895b98d490738b81f3b2360100426be"},
+    {file = "netifaces-0.11.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c37a1ca83825bc6f54dddf5277e9c65dec2f1b4d0ba44b8fd42bc30c91aa6ea1"},
+    {file = "netifaces-0.11.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:28f4bf3a1361ab3ed93c5ef360c8b7d4a4ae060176a3529e72e5e4ffc4afd8b0"},
+    {file = "netifaces-0.11.0-cp36-cp36m-win32.whl", hash = "sha256:2650beee182fed66617e18474b943e72e52f10a24dc8cac1db36c41ee9c041b7"},
+    {file = "netifaces-0.11.0-cp36-cp36m-win_amd64.whl", hash = "sha256:cb925e1ca024d6f9b4f9b01d83215fd00fe69d095d0255ff3f64bffda74025c8"},
+    {file = "netifaces-0.11.0-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:84e4d2e6973eccc52778735befc01638498781ce0e39aa2044ccfd2385c03246"},
+    {file = "netifaces-0.11.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:18917fbbdcb2d4f897153c5ddbb56b31fa6dd7c3fa9608b7e3c3a663df8206b5"},
+    {file = "netifaces-0.11.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:48324183af7f1bc44f5f197f3dad54a809ad1ef0c78baee2c88f16a5de02c4c9"},
+    {file = "netifaces-0.11.0-cp37-cp37m-win32.whl", hash = "sha256:8f7da24eab0d4184715d96208b38d373fd15c37b0dafb74756c638bd619ba150"},
+    {file = "netifaces-0.11.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2479bb4bb50968089a7c045f24d120f37026d7e802ec134c4490eae994c729b5"},
+    {file = "netifaces-0.11.0-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:3ecb3f37c31d5d51d2a4d935cfa81c9bc956687c6f5237021b36d6fdc2815b2c"},
+    {file = "netifaces-0.11.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:96c0fe9696398253f93482c84814f0e7290eee0bfec11563bd07d80d701280c3"},
+    {file = "netifaces-0.11.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c92ff9ac7c2282009fe0dcb67ee3cd17978cffbe0c8f4b471c00fe4325c9b4d4"},
+    {file = "netifaces-0.11.0-cp38-cp38-win32.whl", hash = "sha256:d07b01c51b0b6ceb0f09fc48ec58debd99d2c8430b09e56651addeaf5de48048"},
+    {file = "netifaces-0.11.0-cp38-cp38-win_amd64.whl", hash = "sha256:469fc61034f3daf095e02f9f1bbac07927b826c76b745207287bc594884cfd05"},
+    {file = "netifaces-0.11.0-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:5be83986100ed1fdfa78f11ccff9e4757297735ac17391b95e17e74335c2047d"},
+    {file = "netifaces-0.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:54ff6624eb95b8a07e79aa8817288659af174e954cca24cdb0daeeddfc03c4ff"},
+    {file = "netifaces-0.11.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:841aa21110a20dc1621e3dd9f922c64ca64dd1eb213c47267a2c324d823f6c8f"},
+    {file = "netifaces-0.11.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e76c7f351e0444721e85f975ae92718e21c1f361bda946d60a214061de1f00a1"},
+    {file = "netifaces-0.11.0.tar.gz", hash = "sha256:043a79146eb2907edf439899f262b3dfe41717d34124298ed281139a8b93ca32"},
+]
+
 [[package]]
 name = "numpy"
 version = "1.24.4"

+ 1 - 0
pyproject.toml

@@ -24,6 +24,7 @@ orjson = {version = "^3.8.6", markers = "platform_machine != 'i386' and platform
 importlib_metadata = { version = "^6.0.0", markers = "python_version ~= '3.7'" } # Python 3.7 has no importlib.metadata
 itsdangerous = "^2.1.2"
 aiofiles = "^23.1.0"
+netifaces = "^0.11.0"
 pywebview = { version = "^4.0.2", optional = true }
 plotly = { version = "^5.13.0", optional = true }
 matplotlib = { version = "^3.5.0", optional = true }

+ 14 - 0
tests/test_table.py

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

+ 1 - 0
website/documentation.py

@@ -168,6 +168,7 @@ def create_full() -> None:
         ui.button('Clear', on_click=container.clear)
 
     load_demo(ui.expansion)
+    load_demo(ui.scroll_area)
     load_demo(ui.separator)
     load_demo(ui.splitter)
     load_demo('tabs')

+ 12 - 0
website/more_documentation/query_documentation.py

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

+ 46 - 0
website/more_documentation/scroll_area_documentation.py

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