瀏覽代碼

Merge branch 'main' into echart-enhancements

# Conflicts:
#	nicegui/elements/echart.js
Falko Schindler 1 年之前
父節點
當前提交
9885d11c75

+ 32 - 5
examples/authentication/main.py

@@ -1,33 +1,60 @@
 #!/usr/bin/env python3
-"""This is just a very simple authentication example.
+"""This is just a simple authentication example.
 
 Please see the `OAuth2 example at FastAPI <https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/>`_  or
 use the great `Authlib package <https://docs.authlib.org/en/v0.13/client/starlette.html#using-fastapi>`_ to implement a classing real authentication system.
 Here we just demonstrate the NiceGUI integration.
 """
+from typing import Optional
+
+from fastapi import Request
 from fastapi.responses import RedirectResponse
+from starlette.middleware.base import BaseHTTPMiddleware
 
+import nicegui.globals
 from nicegui import app, ui
 
 # in reality users passwords would obviously need to be hashed
 passwords = {'user1': 'pass1', 'user2': 'pass2'}
 
+unrestricted_page_routes = {'/login'}
+
+
+class AuthMiddleware(BaseHTTPMiddleware):
+    """This middleware restricts access to all NiceGUI pages.
+
+    It redirects the user to the login page if they are not authenticated.
+    """
+
+    async def dispatch(self, request: Request, call_next):
+        if not app.storage.user.get('authenticated', False):
+            if request.url.path in nicegui.globals.page_routes.values() and request.url.path not in unrestricted_page_routes:
+                app.storage.user['referrer_path'] = request.url.path  # remember where the user wanted to go
+                return RedirectResponse('/login')
+        return await call_next(request)
+
+
+app.add_middleware(AuthMiddleware)
+
 
 @ui.page('/')
 def main_page() -> None:
-    if not app.storage.user.get('authenticated', False):
-        return RedirectResponse('/login')
     with ui.column().classes('absolute-center items-center'):
         ui.label(f'Hello {app.storage.user["username"]}!').classes('text-2xl')
         ui.button(on_click=lambda: (app.storage.user.clear(), ui.open('/login')), icon='logout').props('outline round')
 
 
+@ui.page('/subpage')
+def test_page() -> None:
+    ui.label('This is a sub page.')
+
+
 @ui.page('/login')
-def login() -> None:
+def login() -> Optional[RedirectResponse]:
     def try_login() -> None:  # local function to avoid passing username and password as arguments
         if passwords.get(username.value) == password.value:
             app.storage.user.update({'username': username.value, 'authenticated': True})
-            ui.open('/')
+            ui.open(app.storage.user.get('referrer_path', '/'))  # go back to where the user wanted to go
         else:
             ui.notify('Wrong username or password', color='negative')
 

+ 40 - 0
examples/custom_binding/main.py

@@ -0,0 +1,40 @@
+#!/usr/bin/env python3
+import random
+from typing import Optional
+
+from nicegui import ui
+from nicegui.binding import BindableProperty, bind_from
+
+
+class colorful_label(ui.label):
+    """A label with a bindable background color."""
+
+    # This class variable defines what happens when the background property changes.
+    background = BindableProperty(on_change=lambda sender, value: sender.on_background_change(value))
+
+    def __init__(self, text: str = '') -> None:
+        super().__init__(text)
+        self.background: Optional[str] = None  # initialize the background property
+
+    def on_background_change(self, bg_class: str) -> None:
+        """Update the classes of the label when the background property changes."""
+        self._classes = [c for c in self._classes if not c.startswith('bg-')]
+        self._classes.append(bg_class)
+        self.update()
+
+
+temperatures = {'Berlin': 5, 'New York': 15, 'Tokio': 25}
+ui.button(icon='refresh', on_click=lambda: temperatures.update({city: random.randint(0, 30) for city in temperatures}))
+
+
+for city in temperatures:
+    label = colorful_label().classes('w-48 text-center') \
+        .bind_text_from(temperatures, city, backward=lambda t, city=city: f'{city} ({t}°C)')
+    # Bind background color from temperature.
+    # There is also a bind_to method which would propagate changes from the label to the temperatures dictionary
+    # and a bind method which would propagate changes both ways.
+    bind_from(self_obj=label, self_name='background',
+              other_obj=temperatures, other_name=city,
+              backward=lambda t: 'bg-green' if t < 10 else 'bg-yellow' if t < 20 else 'bg-orange')
+
+ui.run()

+ 1 - 0
main.py

@@ -349,6 +349,7 @@ async def index_page(client: Client) -> None:
                          '[zauberzeug/nicegui](https://hub.docker.com/r/zauberzeug/nicegui) docker image')
             example_link('Download Text as File', 'providing in-memory data like strings as file download')
             example_link('Generate PDF', 'create SVG preview and PDF download from input form elements')
+            example_link('Custom Binding', 'create a custom binding for a label with a bindable background color')
 
     with ui.row().classes('dark-box min-h-screen mt-16'):
         link_target('why')

+ 1 - 0
nicegui/air.py

@@ -55,6 +55,7 @@ class Air:
 
         @self.relay.on('ready')
         def on_ready(data: Dict[str, Any]) -> None:
+            globals.app.urls.add(data['device_url'])
             print(f'NiceGUI is on air at {data["device_url"]}', flush=True)
 
         @self.relay.on('error')

+ 2 - 0
nicegui/app.py

@@ -7,6 +7,7 @@ from fastapi.staticfiles import StaticFiles
 
 from . import globals, helpers  # pylint: disable=redefined-builtin
 from .native import Native
+from .observables import ObservableSet
 from .storage import Storage
 
 
@@ -16,6 +17,7 @@ class App(FastAPI):
         super().__init__(**kwargs)
         self.native = Native()
         self.storage = Storage()
+        self.urls = ObservableSet()
 
     def on_connect(self, handler: Union[Callable, Awaitable]) -> None:
         """Called every time a new client connects to NiceGUI.

+ 7 - 14
nicegui/elements/echart.js

@@ -6,7 +6,7 @@ export default {
     this.chart = echarts.init(this.$el);
     convertDynamicProperties(this.options, true);
     this.chart.setOption(this.options);
-    this.chart.resize();
+    new ResizeObserver(this.chart.resize).observe(this.$el);
     function unpack(e) {
       return {
         component_type: e.componentType,
@@ -17,28 +17,21 @@ export default {
         data_index: e.dataIndex,
         data: e.data,
         data_type: e.dataType,
-        value: e.value
+        value: e.value,
       };
     }
-    this.chart.on('click', e => this.$emit("pointClick", unpack(e)));
+    this.chart.on("click", (e) => this.$emit("pointClick", unpack(e)));
   },
   beforeDestroy() {
-    this.destroyChart();
+    this.chart.dispose();
   },
   beforeUnmount() {
-    this.destroyChart();
+    this.chart.dispose();
   },
   methods: {
     update_chart() {
-      if (this.chart) {
-        convertDynamicProperties(this.options, true);
-        this.chart.setOption(this.options);
-      }
-    },
-    destroyChart() {
-      if (this.chart) {
-        this.chart.dispose();
-      }
+      convertDynamicProperties(this.options, true);
+      this.chart.setOption(this.options);
     },
   },
   props: {

+ 21 - 11
nicegui/elements/interactive_image.js

@@ -1,7 +1,15 @@
 export default {
   template: `
     <div style="position:relative">
-      <img ref="img" :src="computed_src" style="width:100%; height:100%;" v-on="onEvents" draggable="false" />
+      <img
+        ref="img"
+        :src="computed_src"
+        style="width:100%; height:100%;"
+        @load="onImageLoaded"
+        v-on="onCrossEvents"
+        v-on="onUserEvents"
+        draggable="false"
+      />
       <svg style="position:absolute;top:0;left:0;pointer-events:none" :viewBox="viewBox">
         <g v-if="cross" :style="{ display: cssDisplay }">
           <line :x1="x" y1="0" :x2="x" y2="100%" stroke="black" />
@@ -74,18 +82,20 @@ export default {
     },
   },
   computed: {
-    onEvents() {
-      const allEvents = {};
+    onCrossEvents() {
+      if (!this.cross) return {};
+      return {
+        mouseenter: () => (this.cssDisplay = "block"),
+        mouseleave: () => (this.cssDisplay = "none"),
+        mousemove: (event) => this.updateCrossHair(event),
+      };
+    },
+    onUserEvents() {
+      const events = {};
       for (const type of this.events) {
-        allEvents[type] = (event) => this.onMouseEvent(type, event);
-      }
-      if (this.cross) {
-        allEvents["mouseenter"] = () => (this.cssDisplay = "block");
-        allEvents["mouseleave"] = () => (this.cssDisplay = "none");
-        allEvents["mousemove"] = (event) => this.updateCrossHair(event);
+        events[type] = (event) => this.onMouseEvent(type, event);
       }
-      allEvents["load"] = (event) => this.onImageLoaded(event);
-      return allEvents;
+      return events;
     },
   },
   props: {

+ 7 - 0
nicegui/elements/stepper.py

@@ -13,16 +13,23 @@ class Stepper(ValueElement):
     def __init__(self, *,
                  value: Union[str, Step, None] = None,
                  on_value_change: Optional[Callable[..., Any]] = None,
+                 keep_alive: bool = True,
                  ) -> None:
         """Stepper
 
         This element represents `Quasar's QStepper <https://quasar.dev/vue-components/stepper#qstepper-api>`_ component.
         It contains individual steps.
 
+        To avoid issues with dynamic elements when switching steps,
+        this element uses Vue's `keep-alive <https://vuejs.org/guide/built-ins/keep-alive.html>`_ component.
+        If client-side performance is an issue, you can disable this feature.
+
         :param value: `ui.step` or name of the step to be initially selected (default: `None` meaning the first step)
         :param on_value_change: callback to be executed when the selected step changes
+        :param keep_alive: whether to use Vue's keep-alive component on the content (default: `True`)
         """
         super().__init__(tag='q-stepper', value=value, on_value_change=on_value_change)
+        self._props['keep-alive'] = keep_alive
 
     def _value_to_model_value(self, value: Any) -> Any:
         return value._props['name'] if isinstance(value, Step) else value  # pylint: disable=protected-access

+ 4 - 4
nicegui/elements/table.py

@@ -1,4 +1,4 @@
-from typing import Any, Callable, Dict, List, Literal, Optional
+from typing import Any, Callable, Dict, List, Literal, Optional, Union
 
 from ..element import Element
 from ..events import GenericEventArguments, TableSelectionEventArguments, handle_event
@@ -13,7 +13,7 @@ class Table(FilterElement, component='table.js'):
                  row_key: str = 'id',
                  title: Optional[str] = None,
                  selection: Optional[Literal['single', 'multiple']] = None,
-                 pagination: Optional[int] = None,
+                 pagination: Optional[Union[int, dict]] = None,
                  on_select: Optional[Callable[..., Any]] = None,
                  ) -> None:
         """Table
@@ -25,7 +25,7 @@ class Table(FilterElement, component='table.js'):
         :param row_key: name of the column containing unique data identifying the row (default: "id")
         :param title: title of the table
         :param selection: selection type ("single" or "multiple"; default: `None`)
-        :param pagination: number of rows per page (`None` hides the pagination, 0 means "infinite"; default: `None`)
+        :param pagination: A dictionary correlating to a pagination object or number of rows per page (`None` hides the pagination, 0 means "infinite"; default: `None`).
         :param on_select: callback which is invoked when the selection changes
 
         If selection is 'single' or 'multiple', then a `selected` property is accessible containing the selected rows.
@@ -41,7 +41,7 @@ class Table(FilterElement, component='table.js'):
         self._props['row-key'] = row_key
         self._props['title'] = title
         self._props['hide-pagination'] = pagination is None
-        self._props['pagination'] = {'rowsPerPage': pagination or 0}
+        self._props['pagination'] = pagination if isinstance(pagination, dict) else {'rowsPerPage': pagination or 0}
         self._props['selection'] = selection or 'none'
         self._props['selected'] = self.selected
         self._props['fullscreen'] = False

+ 4 - 1
nicegui/elements/toggle.py

@@ -11,6 +11,7 @@ class Toggle(ChoiceElement, DisableableElement):
                  options: Union[List, Dict], *,
                  value: Any = None,
                  on_change: Optional[Callable[..., Any]] = None,
+                 clearable: bool = False,
                  ) -> None:
         """Toggle
 
@@ -20,11 +21,13 @@ class Toggle(ChoiceElement, DisableableElement):
         :param options: a list ['value1', ...] or dictionary `{'value1':'label1', ...}` specifying the options
         :param value: the initial value
         :param on_change: callback to execute when selection changes
+        :param clearable: whether the toggle can be cleared by clicking the selected option
         """
         super().__init__(tag='q-btn-toggle', options=options, value=value, on_change=on_change)
+        self._props['clearable'] = clearable
 
     def _event_args_to_value(self, e: GenericEventArguments) -> Any:
-        return self._values[e.args]
+        return self._values[e.args] if e.args is not None else None
 
     def _value_to_model_value(self, value: Any) -> Any:
         return self._values.index(value) if value in self._values else None

+ 2 - 2
nicegui/events.py

@@ -12,7 +12,7 @@ from .slot import Slot
 if TYPE_CHECKING:
     from .client import Client
     from .element import Element
-    from .observables import ObservableDict, ObservableList, ObservableSet
+    from .observables import ObservableCollection
 
 
 @dataclass(**KWONLY_SLOTS)
@@ -22,7 +22,7 @@ class EventArguments:
 
 @dataclass(**KWONLY_SLOTS)
 class ObservableChangeEventArguments(EventArguments):
-    sender: Union[ObservableDict, ObservableList, ObservableSet]
+    sender: ObservableCollection
 
 
 @dataclass(**KWONLY_SLOTS)

+ 4 - 0
nicegui/nicegui.py

@@ -1,4 +1,5 @@
 import asyncio
+import mimetypes
 import time
 import urllib.parse
 from pathlib import Path
@@ -26,6 +27,9 @@ globals.app = app = App(default_response_class=NiceGUIJSONResponse)
 socket_manager = SocketManager(app=app, mount_location='/_nicegui_ws/', json=json)
 globals.sio = sio = socket_manager._sio  # pylint: disable=protected-access
 
+mimetypes.add_type('text/javascript', '.js')
+mimetypes.add_type('text/css', '.css')
+
 app.add_middleware(GZipMiddleware)
 app.add_middleware(RedirectWithPrefixMiddleware)
 static_files = StaticFiles(

+ 117 - 89
nicegui/observables.py

@@ -1,212 +1,240 @@
-from typing import Any, Callable, Dict, Iterable, List, Set, SupportsIndex, Union, overload
+from __future__ import annotations
 
-from . import events
+import abc
+from typing import Any, Callable, Collection, Dict, Iterable, List, Optional, SupportsIndex, Union
 
+from . import events
 
-class ObservableDict(dict):
 
-    def __init__(self, data: Dict, on_change: Callable) -> None:
-        super().__init__(data)
+class ObservableCollection(abc.ABC):
+
+    def __init__(self, *,
+                 factory: Callable,
+                 data: Optional[Collection],
+                 on_change: Optional[Callable],
+                 _parent: Optional[ObservableCollection],
+                 ) -> None:
+        super().__init__(factory() if data is None else data)  # type: ignore
+        self._parent = _parent
+        self._change_handlers: List[Callable] = [on_change] if on_change else []
+
+    @property
+    def change_handlers(self) -> List[Callable]:
+        """Return a list of all change handlers registered on this collection and its parents."""
+        change_handlers = self._change_handlers[:]
+        if self._parent is not None:
+            change_handlers.extend(self._parent.change_handlers)
+        return change_handlers
+
+    def _handle_change(self) -> None:
+        for handler in self.change_handlers:
+            events.handle_event(handler, events.ObservableChangeEventArguments(sender=self))
+
+    def on_change(self, handler: Callable) -> None:
+        """Register a handler to be called when the collection changes."""
+        self._change_handlers.append(handler)
+
+    def _observe(self, data: Any) -> Any:
+        if isinstance(data, dict):
+            return ObservableDict(data, _parent=self)
+        if isinstance(data, list):
+            return ObservableList(data, _parent=self)
+        if isinstance(data, set):
+            return ObservableSet(data, _parent=self)
+        return data
+
+
+class ObservableDict(ObservableCollection, dict):
+
+    def __init__(self,
+                 data: Dict = None,  # type: ignore
+                 *,
+                 on_change: Optional[Callable] = None,
+                 _parent: Optional[ObservableCollection] = None,
+                 ) -> None:
+        super().__init__(factory=dict, data=data, on_change=on_change, _parent=_parent)
         for key, value in self.items():
-            super().__setitem__(key, make_observable(value, on_change))
-        self.on_change = lambda: events.handle_event(on_change, events.ObservableChangeEventArguments(sender=self))
+            super().__setitem__(key, self._observe(value))
 
     def pop(self, k: Any, d: Any = None) -> Any:
         item = super().pop(k, d)
-        self.on_change()
+        self._handle_change()
         return item
 
     def popitem(self) -> Any:
         item = super().popitem()
-        self.on_change()
+        self._handle_change()
         return item
 
     def update(self, *args: Any, **kwargs: Any) -> None:
-        super().update(make_observable(dict(*args, **kwargs), self.on_change))
-        self.on_change()
+        super().update(self._observe(dict(*args, **kwargs)))
+        self._handle_change()
 
     def clear(self) -> None:
         super().clear()
-        self.on_change()
+        self._handle_change()
 
     def setdefault(self, __key: Any, __default: Any = None) -> Any:
-        item = super().setdefault(__key, make_observable(__default, self.on_change))
-        self.on_change()
+        item = super().setdefault(__key, self._observe(__default))
+        self._handle_change()
         return item
 
     def __setitem__(self, __key: Any, __value: Any) -> None:
-        super().__setitem__(__key, make_observable(__value, self.on_change))
-        self.on_change()
+        super().__setitem__(__key, self._observe(__value))
+        self._handle_change()
 
     def __delitem__(self, __key: Any) -> None:
         super().__delitem__(__key)
-        self.on_change()
+        self._handle_change()
 
     def __or__(self, other: Any) -> Any:
         return super().__or__(other)
 
     def __ior__(self, other: Any) -> Any:
-        super().__ior__(make_observable(dict(other), self.on_change))
-        self.on_change()
+        super().__ior__(self._observe(dict(other)))
+        self._handle_change()
         return self
 
 
-class ObservableList(list):
+class ObservableList(ObservableCollection, list):
 
-    def __init__(self, data: List, on_change: Callable) -> None:
-        super().__init__(data)
+    def __init__(self,
+                 data: List = None,  # type: ignore
+                 *,
+                 on_change: Optional[Callable] = None,
+                 _parent: Optional[ObservableCollection] = None,
+                 ) -> None:
+        super().__init__(factory=list, data=data, on_change=on_change, _parent=_parent)
         for i, item in enumerate(self):
-            super().__setitem__(i, make_observable(item, on_change))
-        self.on_change = lambda: events.handle_event(on_change, events.ObservableChangeEventArguments(sender=self))
+            super().__setitem__(i, self._observe(item))
 
     def append(self, item: Any) -> None:
-        super().append(make_observable(item, self.on_change))
-        self.on_change()
+        super().append(self._observe(item))
+        self._handle_change()
 
     def extend(self, iterable: Iterable) -> None:
-        super().extend(make_observable(list(iterable), self.on_change))
-        self.on_change()
+        super().extend(self._observe(list(iterable)))
+        self._handle_change()
 
     def insert(self, index: SupportsIndex, obj: Any) -> None:
-        super().insert(index, make_observable(obj, self.on_change))
-        self.on_change()
+        super().insert(index, self._observe(obj))
+        self._handle_change()
 
     def remove(self, value: Any) -> None:
         super().remove(value)
-        self.on_change()
+        self._handle_change()
 
     def pop(self, index: SupportsIndex = -1) -> Any:
         item = super().pop(index)
-        self.on_change()
+        self._handle_change()
         return item
 
     def clear(self) -> None:
         super().clear()
-        self.on_change()
+        self._handle_change()
 
     def sort(self, **kwargs: Any) -> None:
         super().sort(**kwargs)
-        self.on_change()
+        self._handle_change()
 
     def reverse(self) -> None:
         super().reverse()
-        self.on_change()
+        self._handle_change()
 
     def __delitem__(self, key: Union[SupportsIndex, slice]) -> None:
         super().__delitem__(key)
-        self.on_change()
+        self._handle_change()
 
     def __setitem__(self, key: Union[SupportsIndex, slice], value: Any) -> None:
-        super().__setitem__(key, make_observable(value, self.on_change))
-        self.on_change()
+        super().__setitem__(key, self._observe(value))
+        self._handle_change()
 
     def __add__(self, other: Any) -> Any:
         return super().__add__(other)
 
     def __iadd__(self, other: Any) -> Any:
-        super().__iadd__(make_observable(other, self.on_change))
-        self.on_change()
+        super().__iadd__(self._observe(other))
+        self._handle_change()
         return self
 
 
-class ObservableSet(set):
+class ObservableSet(ObservableCollection, set):
 
-    def __init__(self, data: set, on_change: Callable) -> None:
-        super().__init__(data)
+    def __init__(self,
+                 data: set = None,  # type: ignore
+                 *,
+                 on_change: Optional[Callable] = None,
+                 _parent: Optional[ObservableCollection] = None,
+                 ) -> None:
+        super().__init__(factory=set, data=data, on_change=on_change, _parent=_parent)
         for item in self:
-            super().add(make_observable(item, on_change))
-        self.on_change = lambda: events.handle_event(on_change, events.ObservableChangeEventArguments(sender=self))
+            super().add(self._observe(item))
 
     def add(self, item: Any) -> None:
-        super().add(make_observable(item, self.on_change))
-        self.on_change()
+        super().add(self._observe(item))
+        self._handle_change()
 
     def remove(self, item: Any) -> None:
         super().remove(item)
-        self.on_change()
+        self._handle_change()
 
     def discard(self, item: Any) -> None:
         super().discard(item)
-        self.on_change()
+        self._handle_change()
 
     def pop(self) -> Any:
         item = super().pop()
-        self.on_change()
+        self._handle_change()
         return item
 
     def clear(self) -> None:
         super().clear()
-        self.on_change()
+        self._handle_change()
 
     def update(self, *s: Iterable[Any]) -> None:
-        super().update(make_observable(set(*s), self.on_change))
-        self.on_change()
+        super().update(self._observe(set(*s)))
+        self._handle_change()
 
     def intersection_update(self, *s: Iterable[Any]) -> None:
         super().intersection_update(*s)
-        self.on_change()
+        self._handle_change()
 
     def difference_update(self, *s: Iterable[Any]) -> None:
         super().difference_update(*s)
-        self.on_change()
+        self._handle_change()
 
     def symmetric_difference_update(self, *s: Iterable[Any]) -> None:
         super().symmetric_difference_update(*s)
-        self.on_change()
+        self._handle_change()
 
     def __or__(self, other: Any) -> Any:
         return super().__or__(other)
 
     def __ior__(self, other: Any) -> Any:
-        super().__ior__(make_observable(other, self.on_change))
-        self.on_change()
+        super().__ior__(self._observe(other))
+        self._handle_change()
         return self
 
     def __and__(self, other: Any) -> set:
         return super().__and__(other)
 
     def __iand__(self, other: Any) -> Any:
-        super().__iand__(make_observable(other, self.on_change))
-        self.on_change()
+        super().__iand__(self._observe(other))
+        self._handle_change()
         return self
 
     def __sub__(self, other: Any) -> set:
         return super().__sub__(other)
 
     def __isub__(self, other: Any) -> Any:
-        super().__isub__(make_observable(other, self.on_change))
-        self.on_change()
+        super().__isub__(self._observe(other))
+        self._handle_change()
         return self
 
     def __xor__(self, other: Any) -> set:
         return super().__xor__(other)
 
     def __ixor__(self, other: Any) -> Any:
-        super().__ixor__(make_observable(other, self.on_change))
-        self.on_change()
+        super().__ixor__(self._observe(other))
+        self._handle_change()
         return self
-
-
-@overload
-def make_observable(data: Dict, on_change: Callable) -> ObservableDict:
-    ...
-
-
-@overload
-def make_observable(data: List, on_change: Callable) -> ObservableList:
-    ...
-
-
-@overload
-def make_observable(data: Set, on_change: Callable) -> ObservableSet:
-    ...
-
-
-def make_observable(data: Any, on_change: Callable) -> Any:
-    if isinstance(data, dict):
-        return ObservableDict(data, on_change)
-    if isinstance(data, list):
-        return ObservableList(data, on_change)
-    if isinstance(data, set):
-        return ObservableSet(data, on_change)
-    return data

+ 1 - 1
nicegui/storage.py

@@ -42,7 +42,7 @@ class PersistentDict(observables.ObservableDict):
     def __init__(self, filepath: Path) -> None:
         self.filepath = filepath
         data = json.loads(filepath.read_text()) if filepath.exists() else {}
-        super().__init__(data, self.backup)
+        super().__init__(data, on_change=self.backup)
 
     def backup(self) -> None:
         if not self.filepath.exists():

+ 5 - 4
nicegui/welcome.py

@@ -36,10 +36,11 @@ async def print_message() -> None:
     loop = asyncio.get_running_loop()
     ips = set((await loop.run_in_executor(None, 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:
-        addresses[-1] = 'and ' + addresses[-1]
+    urls = [(f'http://{ip}:{port}' if port != '80' else f'http://{ip}') for ip in ['localhost'] + sorted(ips)]
+    globals.app.urls.update(urls)
+    if len(urls) >= 2:
+        urls[-1] = 'and ' + urls[-1]
     extra = ''
     if 'netifaces' not in globals.optional_features:
         extra = ' (install netifaces to show all IPs and speedup this message)'
-    print(f'on {", ".join(addresses)}' + extra, flush=True)
+    print(f'on {", ".join(urls)}' + extra, flush=True)

File diff suppressed because it is too large
+ 427 - 365
poetry.lock


+ 1 - 1
pyproject.toml

@@ -12,7 +12,7 @@ keywords = ["gui", "ui", "web", "interface", "live"]
 python = "^3.8"
 typing-extensions = ">=3.10.0"
 markdown2 = "^2.4.7"
-Pygments = ">=2.9.0,<3.0.0"
+Pygments = ">=2.15.1,<3.0.0"
 uvicorn = {extras = ["standard"], version = "^0.22.0"}
 fastapi = ">=0.92,<1.0.0"
 fastapi-socketio = "^0.0.10"

+ 18 - 0
tests/test_interactive_image.py

@@ -1,4 +1,5 @@
 import pytest
+from selenium.webdriver.common.action_chains import ActionChains
 
 from nicegui import Client, ui
 
@@ -57,3 +58,20 @@ def test_replace_interactive_image(screen: Screen):
     screen.click('Replace')
     screen.wait(0.5)
     assert screen.find_by_tag('img').get_attribute('src').endswith('id/30/640/360')
+
+
+@pytest.mark.parametrize('cross', [True, False])
+def test_mousemove_event(screen: Screen, cross: bool):
+    counter = {'value': 0}
+    ii = ui.interactive_image('https://picsum.photos/id/29/640/360', cross=cross, events=['mousemove'],
+                              on_mouse=lambda: counter.update(value=counter['value'] + 1))
+
+    screen.open('/')
+    element = screen.find_element(ii)
+    ActionChains(screen.selenium) \
+        .move_to_element_with_offset(element, 0, 0) \
+        .pause(0.5) \
+        .move_by_offset(10, 10) \
+        .pause(0.5) \
+        .perform()
+    assert counter['value'] > 0

+ 18 - 7
tests/test_observables.py

@@ -2,7 +2,7 @@ import asyncio
 import sys
 
 from nicegui import ui
-from nicegui.observables import make_observable
+from nicegui.observables import ObservableDict, ObservableList, ObservableSet
 
 from .screen import Screen
 
@@ -28,7 +28,7 @@ async def increment_counter_slowly(_):
 
 def test_observable_dict():
     reset_counter()
-    data = make_observable({}, increment_counter)
+    data = ObservableDict(on_change=increment_counter)
     data['a'] = 1
     assert count == 1
     del data['a']
@@ -50,7 +50,7 @@ def test_observable_dict():
 
 def test_observable_list():
     reset_counter()
-    data = make_observable([], increment_counter)
+    data = ObservableList(on_change=increment_counter)
     data.append(1)
     assert count == 1
     data.extend([2, 3, 4])
@@ -81,7 +81,7 @@ def test_observable_list():
 
 def test_observable_set():
     reset_counter()
-    data = make_observable({1, 2, 3, 4, 5}, increment_counter)
+    data = ObservableSet({1, 2, 3, 4, 5}, on_change=increment_counter)
     data.add(1)
     assert count == 1
     data.remove(1)
@@ -112,12 +112,12 @@ def test_observable_set():
 
 def test_nested_observables():
     reset_counter()
-    data = make_observable({
+    data = ObservableDict({
         'a': 1,
         'b': [1, 2, 3, {'x': 1, 'y': 2, 'z': 3}],
         'c': {'x': 1, 'y': 2, 'z': 3, 't': [1, 2, 3]},
         'd': {1, 2, 3},
-    }, increment_counter)
+    }, on_change=increment_counter)
     data['a'] = 42
     assert count == 1
     data['b'].append(4)
@@ -134,7 +134,7 @@ def test_nested_observables():
 
 def test_async_handler(screen: Screen):
     reset_counter()
-    data = make_observable([], increment_counter_slowly)
+    data = ObservableList(on_change=increment_counter_slowly)
     ui.button('Append 42', on_click=lambda: data.append(42))
 
     screen.open('/')
@@ -143,3 +143,14 @@ def test_async_handler(screen: Screen):
     screen.click('Append 42')
     screen.wait(0.5)
     assert count == 1
+
+
+def test_setting_change_handler():
+    reset_counter()
+    data = ObservableList()
+    data.append(1)
+    assert count == 0
+
+    data.on_change(increment_counter)
+    data.append(2)
+    assert count == 1

+ 14 - 1
tests/test_serving_files.py

@@ -3,8 +3,9 @@ from pathlib import Path
 
 import httpx
 import pytest
+import requests
 
-from nicegui import app, ui
+from nicegui import __version__, app, ui
 
 from .screen import Screen
 from .test_helpers import TEST_DIR
@@ -81,3 +82,15 @@ def test_auto_serving_file_from_video_source(screen: Screen):
     video = screen.find_by_tag('video')
     assert '/_nicegui/auto/media/' in video.get_attribute('src')
     assert_video_file_streaming(video.get_attribute('src'))
+
+
+def test_mimetypes_of_static_files(screen: Screen):
+    screen.open('/')
+
+    response = requests.get(f'http://localhost:{Screen.PORT}/_nicegui/{__version__}/static/vue.global.js', timeout=5)
+    assert response.status_code == 200
+    assert response.headers['Content-Type'].startswith('text/javascript')
+
+    response = requests.get(f'http://localhost:{Screen.PORT}/_nicegui/{__version__}/static/nicegui.css', timeout=5)
+    assert response.status_code == 200
+    assert response.headers['Content-Type'].startswith('text/css')

+ 11 - 1
tests/test_table.py

@@ -33,7 +33,7 @@ def test_table(screen: Screen):
     screen.should_contain('Lionel')
 
 
-def test_pagination(screen: Screen):
+def test_pagination_int(screen: Screen):
     ui.table(columns=columns(), rows=rows(), pagination=2)
 
     screen.open('/')
@@ -43,6 +43,16 @@ def test_pagination(screen: Screen):
     screen.should_contain('1-2 of 3')
 
 
+def test_pagination_dict(screen: Screen):
+    ui.table(columns=columns(), rows=rows(), pagination={'rowsPerPage': 2})
+
+    screen.open('/')
+    screen.should_contain('Alice')
+    screen.should_contain('Bob')
+    screen.should_not_contain('Lionel')
+    screen.should_contain('1-2 of 3')
+
+
 def test_filter(screen: Screen):
     table = ui.table(columns=columns(), rows=rows())
     ui.input('Search by name').bind_value(table, 'filter')

+ 12 - 0
tests/test_toggle.py

@@ -35,3 +35,15 @@ def test_changing_options(screen: Screen):
     screen.should_contain('value = 10')
     screen.click('clear')
     screen.should_contain('value = None')
+
+
+def test_clearable_toggle(screen: Screen):
+    t = ui.toggle(['A', 'B', 'C'], clearable=True)
+    ui.label().bind_text_from(t, 'value', lambda v: f'value = {v}')
+
+    screen.open('/')
+    screen.click('A')
+    screen.should_contain('value = A')
+
+    screen.click('A')
+    screen.should_contain('value = None')

+ 16 - 6
website/demo.py

@@ -4,7 +4,7 @@ from typing import Callable, Literal, Optional, Union
 
 import isort
 
-from nicegui import ui
+from nicegui import helpers, ui
 
 from .intersection_observer import IntersectionObserver as intersection_observer
 
@@ -53,8 +53,15 @@ def demo(f: Callable) -> Callable:
                 .on('click', copy_code, [])
         with browser_window(title=getattr(f, 'tab', None),
                             classes='w-full max-w-[44rem] min-[1500px]:max-w-[20rem] min-h-[10rem] browser-window') as window:
-            ui.spinner(size='lg').props('thickness=2')
-            intersection_observer(on_intersection=lambda: (window.clear(), f()))
+            spinner = ui.spinner(size='lg').props('thickness=2')
+
+            async def handle_intersection():
+                window.remove(spinner)
+                if helpers.is_coroutine_function(f):
+                    await f()
+                else:
+                    f()
+            intersection_observer(on_intersection=handle_intersection)
     return f
 
 
@@ -65,9 +72,9 @@ def _dots() -> None:
         ui.icon('circle').classes('text-[13px] text-green-400')
 
 
-def window(type: WindowType, *, title: str = '', tab: Union[str, Callable] = '', classes: str = '') -> ui.column:
+def window(type_: WindowType, *, title: str = '', tab: Union[str, Callable] = '', classes: str = '') -> ui.column:
     bar_color = ('#00000010', '#ffffff10')
-    color = WINDOW_BG_COLORS[type]
+    color = WINDOW_BG_COLORS[type_]
     with ui.card().classes(f'no-wrap bg-[{color[0]}] dark:bg-[{color[1]}] rounded-xl p-0 gap-0 {classes}') \
             .style('box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1)'):
         with ui.row().classes(f'w-full h-8 p-2 bg-[{bar_color[0]}] dark:bg-[{bar_color[1]}]'):
@@ -82,7 +89,10 @@ def window(type: WindowType, *, title: str = '', tab: Union[str, Callable] = '',
                         ui.label().classes(
                             f'w-full h-full bg-[{bar_color[0]}] dark:bg-[{bar_color[1]}] rounded-br-[6px]')
                     with ui.row().classes(f'text-sm text-gray-600 dark:text-gray-400 px-6 py-1 h-[24px] rounded-t-[6px] bg-[{color[0]}] dark:bg-[{color[1]}] items-center gap-2'):
-                        tab() if callable(tab) else ui.label(tab)
+                        if callable(tab):
+                            tab()
+                        else:
+                            ui.label(tab)
                     with ui.label().classes(f'w-2 h-[24px] bg-[{color[0]}] dark:bg-[{color[1]}]'):
                         ui.label().classes(
                             f'w-full h-full bg-[{bar_color[0]}] dark:bg-[{bar_color[1]}] rounded-bl-[6px]')

+ 17 - 2
website/documentation.py

@@ -531,6 +531,21 @@ def create_full() -> None:
         ui.button('shutdown', on_click=lambda: ui.notify(
             'Nah. We do not actually shutdown the documentation server. Try it in your own app!'))
 
+    @text_demo('URLs', '''
+        You can access the list of all URLs on which the NiceGUI app is available via `app.urls`.
+        The URLs are not available in `app.on_startup` because the server is not yet running.
+        Instead, you can access them in a page function or register a callback with `app.urls.on_change`.
+    ''')
+    def urls_demo():
+        from nicegui import app
+
+        # @ui.page('/')
+        # def index():
+        #     for url in app.urls:
+        #         ui.link(url, target=url)
+        # END OF DEMO
+        ui.link('https://nicegui.io', target='https://nicegui.io')
+
     heading('NiceGUI Fundamentals')
 
     @text_demo('Auto-context', '''
@@ -681,7 +696,7 @@ def create_full() -> None:
 
                 ui.label('Hello from PyInstaller')
 
-                ui.run(reload=False, native_mode.find_open_port())
+                ui.run(reload=False, port=native_mode.find_open_port())
                 ```
             ''')
         with demo.python_window('build.py', classes='max-w-lg w-full'):
@@ -764,7 +779,7 @@ def create_full() -> None:
         sys.stdout = open('logs.txt', 'w')
         ```
         See <https://github.com/zauberzeug/nicegui/issues/681> for more information.
-    ''')
+    ''').classes('bold-links arrow-links')
 
     subheading('NiceGUI On Air')
 

+ 1 - 1
website/more_documentation/aggrid_documentation.py

@@ -142,7 +142,7 @@ def more() -> None:
         All AG Grid events are passed through to NiceGUI via the AG Grid global listener.
         These events can be subscribed to using the `.on()` method.
     ''')
-    def aggrid_with_html_columns():
+    def aggrid_respond_to_event():
         ui.aggrid({
             'columnDefs': [
                 {'headerName': 'Name', 'field': 'name'},

+ 2 - 2
website/more_documentation/button_documentation.py

@@ -4,14 +4,14 @@ from ..documentation_tools import text_demo
 
 
 def main_demo() -> None:
-    ui.button('Click me!', on_click=lambda: ui.notify(f'You clicked me!'))
+    ui.button('Click me!', on_click=lambda: ui.notify('You clicked me!'))
 
 
 def more() -> None:
     @text_demo('Icons', '''
         You can also add an icon to a button.
     ''')
-    async def icons() -> None:
+    def icons() -> None:
         with ui.row():
             ui.button('demo', icon='history')
             ui.button(icon='thumb_up')

+ 5 - 0
website/more_documentation/generic_events_documentation.py

@@ -18,6 +18,11 @@ def main_demo() -> None:
     The generic event handler can be synchronous or asynchronous and optionally takes `GenericEventArguments` as argument ("E").
     You can also specify which attributes of the JavaScript or Quasar event should be passed to the handler ("F").
     This can reduce the amount of data that needs to be transferred between the server and the client.
+
+    Here you can find more information about the events that are supported:
+
+    - https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement#events for HTML elements
+    - https://quasar.dev/vue-components for Quasar-based elements (see the "Events" tab on the individual component page)
     """
     with ui.row():
         ui.button('A', on_click=lambda: ui.notify('You clicked the button A.'))

+ 2 - 2
website/more_documentation/icon_documentation.py

@@ -14,7 +14,7 @@ def more() -> None:
     @text_demo('Eva icons', '''
         You can use [Eva icons](https://akveo.github.io/eva-icons/) in your app.
     ''')
-    async def eva_icons():
+    def eva_icons():
         # ui.add_head_html('<link href="https://unpkg.com/eva-icons@1.1.3/style/eva-icons.css" rel="stylesheet">')
 
         ui.element('i').classes('eva eva-github').classes('text-5xl')
@@ -22,7 +22,7 @@ def more() -> None:
     @text_demo('Lottie files', '''
         You can also use [Lottie files](https://lottiefiles.com/) with animations.
     ''')
-    async def lottie():
+    def lottie():
         # ui.add_body_html('<script src="https://unpkg.com/@lottiefiles/lottie-player@latest/dist/lottie-player.js"></script>')
 
         src = 'https://assets5.lottiefiles.com/packages/lf20_MKCnqtNQvg.json'

+ 3 - 3
website/more_documentation/input_documentation.py

@@ -16,14 +16,14 @@ def more() -> None:
         The `autocomplete` feature provides suggestions as you type, making input easier and faster.
         The parameter `options` is a list of strings that contains the available options that will appear.
     ''')
-    async def autocomplete_demo():
+    def autocomplete_demo():
         options = ['AutoComplete', 'NiceGUI', 'Awesome']
         ui.input(label='Text', placeholder='start typing', autocomplete=options)
 
     @text_demo('Clearable', '''
         The `clearable` prop from [Quasar](https://quasar.dev/) adds a button to the input that clears the text.    
     ''')
-    async def clearable():
+    def clearable():
         i = ui.input(value='some text').props('clearable')
         ui.label().bind_text_from(i, 'value')
 
@@ -32,7 +32,7 @@ def more() -> None:
         It is even possible to style the underlying input with `input-style` and `input-class` props
         and use the provided slots to add custom elements.
     ''')
-    async def styling():
+    def styling():
         ui.input(placeholder='start typing').props('rounded outlined dense')
         ui.input('styling', value='some text') \
             .props('input-style="color: blue" input-class="font-mono"')

+ 1 - 1
website/more_documentation/number_documentation.py

@@ -14,6 +14,6 @@ def more() -> None:
     @text_demo('Clearable', '''
         The `clearable` prop from [Quasar](https://quasar.dev/) adds a button to the input that clears the text.    
     ''')
-    async def clearable():
+    def clearable():
         i = ui.number(value=42).props('clearable')
         ui.label().bind_text_from(i, 'value')

+ 2 - 0
website/more_documentation/storage_documentation.py

@@ -15,11 +15,13 @@ def main_demo() -> None:
     - `app.storage.user`:
         Stored server-side, each dictionary is associated with a unique identifier held in a browser session cookie.
         Unique to each user, this storage is accessible across all their browser tabs.
+        `app.storage.browser['id']` is used to identify the user.
     - `app.storage.general`:
         Also stored server-side, this dictionary provides a shared storage space accessible to all users.
     - `app.storage.browser`:
         Unlike the previous types, this dictionary is stored directly as the browser session cookie, shared among all browser tabs for the same user.
         However, `app.storage.user` is generally preferred due to its advantages in reducing data payload, enhancing security, and offering larger storage capacity.
+        By default, NiceGUI holds a unique identifier for the browser session in `app.storage.browser['id']`.
 
     The user storage and browser storage are only available within `page builder functions </documentation/page>`_
     because they are accessing the underlying `Request` object from FastAPI.

+ 26 - 0
website/more_documentation/table_documentation.py

@@ -201,3 +201,29 @@ def more() -> None:
                 table.toggle_fullscreen()
                 button.props('icon=fullscreen_exit' if table.is_fullscreen else 'icon=fullscreen')
             button = ui.button('Toggle fullscreen', icon='fullscreen', on_click=toggle).props('flat')
+
+    @text_demo('Pagination', '''
+        You can provide either a single integer or a dictionary to define pagination.
+
+        The dictionary can contain the following keys:
+
+        - `rowsPerPage`: The number of rows per page.
+        - `sortBy`: The column name to sort by.
+        - `descending`: Whether to sort in descending order.
+        - `page`: The current page (1-based).
+    ''')
+    def pagination() -> None:
+        columns = [
+            {'name': 'name', 'label': 'Name', 'field': 'name', 'required': True, 'align': 'left'},
+            {'name': 'age', 'label': 'Age', 'field': 'age', 'sortable': True},
+        ]
+        rows = [
+            {'name': 'Elsa', 'age': 18},
+            {'name': 'Oaken', 'age': 46},
+            {'name': 'Hans', 'age': 20},
+            {'name': 'Sven'},
+            {'name': 'Olaf', 'age': 4},
+            {'name': 'Anna', 'age': 17},
+        ]
+        ui.table(columns=columns, rows=rows, pagination=3)
+        ui.table(columns=columns, rows=rows, pagination={'rowsPerPage': 4, 'sortBy': 'age', 'page': 2})

+ 1 - 1
website/more_documentation/textarea_documentation.py

@@ -14,6 +14,6 @@ def more() -> None:
     @text_demo('Clearable', '''
         The `clearable` prop from [Quasar](https://quasar.dev/) adds a button to the input that clears the text.    
     ''')
-    async def clearable():
+    def clearable():
         i = ui.textarea(value='some text').props('clearable')
         ui.label().bind_text_from(i, 'value')

File diff suppressed because it is too large
+ 10 - 0
website/static/search_index.json


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