Repository: zauberzeug/nicegui
Pull request #3279 (2024-06-24 16:56:40)

Add demos for using `ui.refreshable` with local scope
Inspired by #2535 and #3233, this PR adds 4 demos showing how to use `ui.refreshable` with different scope.

falkoschindler: @rodja I simplified the demos by removing the `ui.input` element with access to the user storage and showing the current time instead.

Patch:
diff --git a/nicegui/client.py b/nicegui/client.py
index 4372a97b..f016e11a 100644
--- a/nicegui/client.py
+++ b/nicegui/client.py
@@ -46,7 +46,8 @@ class Client:
     shared_body_html = ''
     """HTML to be inserted in the <body> of every page template."""
 
-    def __init__(self, page: page, *, shared: bool = False) -> None:
+    def __init__(self, page: page, *, request: Optional[Request]) -> None:
+        self.request: Optional[Request] = request
         self.id = str(uuid.uuid4())
         self.created = time.time()
         self.instances[self.id] = self
@@ -56,7 +57,7 @@ class Client:
         self.is_waiting_for_connection: bool = False
         self.is_waiting_for_disconnect: bool = False
         self.environ: Optional[Dict[str, Any]] = None
-        self.shared = shared
+        self.shared = request is None
         self.on_air = False
         self._disconnect_task: Optional[asyncio.Task] = None
         self._deleted = False
diff --git a/nicegui/elements/number.py b/nicegui/elements/number.py
index 9f024cfe..e4691d95 100644
--- a/nicegui/elements/number.py
+++ b/nicegui/elements/number.py
@@ -133,6 +133,3 @@ class Number(ValidationElement, DisableableElement):
         if value == '':
             return 0
         return self.format % float(value)
-
-    def _value_to_event_value(self, value: Any) -> Any:
-        return float(value) if value else 0
diff --git a/nicegui/elements/range.py b/nicegui/elements/range.py
index 69afe616..bd66cb66 100644
--- a/nicegui/elements/range.py
+++ b/nicegui/elements/range.py
@@ -27,3 +27,39 @@ class Range(ValueElement, DisableableElement):
         self._props['min'] = min
         self._props['max'] = max
         self._props['step'] = step
+
+    @property
+    def min(self) -> float:
+        """The minimum value allowed."""
+        return self._props['min']
+
+    @min.setter
+    def min(self, value: float) -> None:
+        if self._props['min'] == value:
+            return
+        self._props['min'] = value
+        self.update()
+
+    @property
+    def max(self) -> float:
+        """The maximum value allowed."""
+        return self._props['max']
+
+    @max.setter
+    def max(self, value: float) -> None:
+        if self._props['max'] == value:
+            return
+        self._props['max'] = value
+        self.update()
+
+    @property
+    def step(self) -> float:
+        """The step size between valid values."""
+        return self._props['step']
+
+    @step.setter
+    def step(self, value: float) -> None:
+        if self._props['step'] == value:
+            return
+        self._props['step'] = value
+        self.update()
diff --git a/nicegui/elements/scene.js b/nicegui/elements/scene.js
index 2821d8e3..26ddc010 100644
--- a/nicegui/elements/scene.js
+++ b/nicegui/elements/scene.js
@@ -123,16 +123,18 @@ export default {
     this.$nextTick(() => this.resize());
     window.addEventListener("resize", this.resize, false);
 
+    const gridSize = this.grid[0] || 100;
+    const gridDivisions = this.grid[1] || 100;
     if (this.grid) {
       const ground = new THREE.Mesh(
-        new THREE.PlaneGeometry(100, 100),
+        new THREE.PlaneGeometry(gridSize, gridSize),
         new THREE.MeshPhongMaterial({ color: this.background_color })
       );
       ground.translateZ(-0.01);
       ground.object_id = "ground";
       this.scene.add(ground);
 
-      const grid = new THREE.GridHelper(100, 100);
+      const grid = new THREE.GridHelper(gridSize, gridDivisions);
       grid.material.transparent = true;
       grid.material.opacity = 0.2;
       grid.rotateX(Math.PI / 2);
@@ -470,7 +472,7 @@ export default {
   props: {
     width: Number,
     height: Number,
-    grid: Boolean,
+    grid: Object,
     camera_type: String,
     camera_params: Object,
     drag_constraints: String,
diff --git a/nicegui/elements/scene.py b/nicegui/elements/scene.py
index 0ce5a5f1..14f3e439 100644
--- a/nicegui/elements/scene.py
+++ b/nicegui/elements/scene.py
@@ -1,6 +1,6 @@
 import asyncio
 from dataclasses import dataclass
-from typing import Any, Callable, Dict, List, Literal, Optional, Union
+from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, Union
 
 from typing_extensions import Self
 
@@ -72,7 +72,7 @@ class Scene(Element,
     def __init__(self,
                  width: int = 400,
                  height: int = 300,
-                 grid: bool = True,
+                 grid: Union[bool, Tuple[int, int]] = True,
                  camera: Optional[SceneCamera] = None,
                  on_click: Optional[Callable[..., Any]] = None,
                  on_drag_start: Optional[Callable[..., Any]] = None,
@@ -89,7 +89,7 @@ class Scene(Element,
 
         :param width: width of the canvas
         :param height: height of the canvas
-        :param grid: whether to display a grid
+        :param grid: whether to display a grid (boolean or tuple of ``size`` and ``divisions`` for `Three.js' GridHelper <https://threejs.org/docs/#api/en/helpers/GridHelper>`_, default: 100x100)
         :param camera: camera definition, either instance of ``ui.scene.perspective_camera`` (default) or ``ui.scene.orthographic_camera``
         :param on_click: callback to execute when a 3D object is clicked
         :param on_drag_start: callback to execute when a 3D object is dragged
diff --git a/nicegui/nicegui.py b/nicegui/nicegui.py
index 45b562a6..72baf2d0 100644
--- a/nicegui/nicegui.py
+++ b/nicegui/nicegui.py
@@ -62,7 +62,7 @@ static_files = StaticFiles(
 )
 app.mount(f'/_nicegui/{__version__}/static', static_files, name='static')
 
-Client.auto_index_client = Client(page('/'), shared=True).__enter__()  # pylint: disable=unnecessary-dunder-call
+Client.auto_index_client = Client(page('/'), request=None).__enter__()  # pylint: disable=unnecessary-dunder-call
 
 
 @app.get('/')
@@ -149,7 +149,7 @@ async def _shutdown() -> None:
 @app.exception_handler(404)
 async def _exception_handler_404(request: Request, exception: Exception) -> Response:
     log.warning(f'{request.url} not found')
-    with Client(page('')) as client:
+    with Client(page(''), request=request) as client:
         error_content(404, exception)
     return client.build_response(request, 404)
 
@@ -157,7 +157,7 @@ async def _exception_handler_404(request: Request, exception: Exception) -> Resp
 @app.exception_handler(Exception)
 async def _exception_handler_500(request: Request, exception: Exception) -> Response:
     log.exception(exception)
-    with Client(page('')) as client:
+    with Client(page(''), request=request) as client:
         error_content(500, exception)
     return client.build_response(request, 500)
 
diff --git a/nicegui/page.py b/nicegui/page.py
index b89b51a8..5b974929 100644
--- a/nicegui/page.py
+++ b/nicegui/page.py
@@ -102,7 +102,7 @@ class page:
             request = dec_kwargs['request']
             # NOTE cleaning up the keyword args so the signature is consistent with "func" again
             dec_kwargs = {k: v for k, v in dec_kwargs.items() if k in parameters_of_decorated_func}
-            with Client(self) as client:
+            with Client(self, request=request) as client:
                 if any(p.name == 'client' for p in inspect.signature(func).parameters.values()):
                     dec_kwargs['client'] = client
                 result = func(*dec_args, **dec_kwargs)
diff --git a/website/documentation/content/range_documentation.py b/website/documentation/content/range_documentation.py
index 02e87489..926fe15a 100644
--- a/website/documentation/content/range_documentation.py
+++ b/website/documentation/content/range_documentation.py
@@ -11,7 +11,7 @@ def main_demo() -> None:
 
 
 @doc.demo('Customize labels', '''
-        You can customize the colors of the range and its labels by setting them individually or for the range in total.
+    You can customize the colors of the range and its labels by setting them individually or for the range in total.
 ''')
 def customize_labels():
     ui.label('Color the entire range')
@@ -23,4 +23,16 @@ def customize_labels():
         .props('label-always snap label-color="secondary" right-label-text-color="black"')
 
 
+@doc.demo('Change range limits', '''
+    This demo shows how to change the limits on the click of a button.
+''')
+def range_limits():
+    def increase_limits():
+        r.min -= 10
+        r.max += 10
+
+    ui.button('Increase limits', on_click=increase_limits)
+    r = ui.range(min=0, max=100, value={'min': 30, 'max': 70}).props('label-always')
+
+
 doc.reference(ui.range)
diff --git a/website/documentation/content/refreshable_documentation.py b/website/documentation/content/refreshable_documentation.py
index 521faad3..67f45cce 100644
--- a/website/documentation/content/refreshable_documentation.py
+++ b/website/documentation/content/refreshable_documentation.py
@@ -91,4 +91,91 @@ def reactive_state():
         counter('B')
 
 
+@doc.demo('Global scope', '''
+    When defining a refreshable function in the global scope,
+    every refreshable UI that is created by calling this function will share the same state.
+    In this demo, `time()` will show the current date and time.
+    When opening the page in a new tab, _both_ tabs will be updated simultaneously when clicking the "Refresh" button.
+
+    See the "local scope" demos below for a way to create independent refreshable UIs instead.
+''')
+def global_scope():
+    from datetime import datetime
+
+    @ui.refreshable
+    def time():
+        ui.label(f'Time: {datetime.now()}')
+
+    @ui.page('/global_refreshable')
+    def demo():
+        time()
+        ui.button('Refresh', on_click=time.refresh)
+
+    ui.link('Open demo', demo)
+
+
+@doc.demo('Local scope (variant A)', '''
+    When defining a refreshable function in a local scope,
+    refreshable UI that is created by calling this function will refresh independently.
+    In contrast to the "global scope" demo,
+    the time will be updated only in the tab where the "Refresh" button was clicked.
+''')
+def local_scope_a():
+    from datetime import datetime
+
+    @ui.page('/local_refreshable_a')
+    def demo():
+        @ui.refreshable
+        def time():
+            ui.label(f'Time: {datetime.now()}')
+
+        time()
+        ui.button('Refresh', on_click=time.refresh)
+
+    ui.link('Open demo', demo)
+
+
+@doc.demo('Local scope (variant B)', '''
+    In order to define refreshable UIs with local state outside of page functions,
+    you can, e.g., define a class with a refreshable method.
+    This way, you can create multiple instances of the class with independent state,
+    because the `ui.refreshable` decorator acts on the class instance rather than the class itself.
+''')
+def local_scope_b():
+    from datetime import datetime
+
+    class Clock:
+        @ui.refreshable
+        def time(self):
+            ui.label(f'Time: {datetime.now()}')
+
+    @ui.page('/local_refreshable_b')
+    def demo():
+        clock = Clock()
+        clock.time()
+        ui.button('Refresh', on_click=clock.time.refresh)
+
+    ui.link('Open demo', demo)
+
+
+@doc.demo('Local scope (variant C)', '''
+    As an alternative to the class definition shown above, you can also define the UI function in global scope,
+    but apply the `ui.refreshable` decorator inside the page function.
+    This way the refreshable UI will refresh independently.
+''')
+def local_scope_c():
+    from datetime import datetime
+
+    def time():
+        ui.label(f'Time: {datetime.now()}')
+
+    @ui.page('/local_refreshable_c')
+    def demo():
+        refreshable_time = ui.refreshable(time)
+        refreshable_time()
+        ui.button('Refresh', on_click=refreshable_time.refresh)
+
+    ui.link('Open demo', demo)
+
+
 doc.reference(ui.refreshable)
diff --git a/website/documentation/content/scene_documentation.py b/website/documentation/content/scene_documentation.py
index 92eba17a..693f60aa 100644
--- a/website/documentation/content/scene_documentation.py
+++ b/website/documentation/content/scene_documentation.py
@@ -145,4 +145,13 @@ def custom_background() -> None:
         scene.box()
 
 
+@doc.demo('Custom Grid', '''
+    You can set custom grid parameters using the `grid` parameter of `ui.scene`.
+    It accepts a tuple of two integers, the first one for the grid size and the second one for the number of divisions.
+''')
+def custom_grid() -> None:
+    with ui.scene(grid=(3, 2)).classes('w-full h-64') as scene:
+        scene.sphere()
+
+
 doc.reference(ui.scene)
diff --git a/website/documentation/content/section_configuration_deployment.py b/website/documentation/content/section_configuration_deployment.py
index d9549ce0..cfb9d52b 100644
--- a/website/documentation/content/section_configuration_deployment.py
+++ b/website/documentation/content/section_configuration_deployment.py
@@ -148,8 +148,8 @@ def uvicorn_ssl():
             from nicegui import ui
 
             ui.run(
-                port=443, 
-                ssl_certfile="<path_to_certfile>", 
+                port=443,
+                ssl_certfile="<path_to_certfile>",
                 ssl_keyfile="<path_to_keyfile>",
             )
             ```


Test patch:
diff --git a/nicegui/testing/conftest.py b/nicegui/testing/conftest.py
index 9fae6a6b..66ffd4c0 100644
--- a/nicegui/testing/conftest.py
+++ b/nicegui/testing/conftest.py
@@ -70,7 +70,7 @@ def reset_globals() -> Generator[None, None, None]:
     Client.instances.clear()
     Client.page_routes.clear()
     app.reset()
-    Client.auto_index_client = Client(page('/'), shared=True).__enter__()  # pylint: disable=unnecessary-dunder-call
+    Client.auto_index_client = Client(page('/'), request=None).__enter__()  # pylint: disable=unnecessary-dunder-call
     # NOTE we need to re-add the auto index route because we removed all routes above
     app.get('/')(Client.auto_index_client.build_response)
     binding.reset()
diff --git a/tests/test_number.py b/tests/test_number.py
index 68379d14..0711591b 100644
--- a/tests/test_number.py
+++ b/tests/test_number.py
@@ -126,3 +126,25 @@ def test_changing_limits(screen: Screen):
 
     screen.click('Step up')
     screen.should_contain_input('1')
+
+
+def test_none_values(screen: Screen):
+    n = ui.number('Number', on_change=lambda e: ui.label(f'event: {e.value}'))
+    ui.label().bind_text_from(n, 'value', lambda value: f'model: {value}')
+
+    screen.open('/')
+    element = screen.selenium.find_element(By.XPATH, '//*[@aria-label="Number"]')
+    element.send_keys('0')
+    screen.should_contain_input('0')
+    screen.should_contain('model: 0')
+    screen.should_contain('event: 0')
+
+    element.send_keys(Keys.BACKSPACE)
+    screen.should_contain_input('')
+    screen.should_contain('model: None')
+    screen.should_contain('event: None')
+
+    element.send_keys('1')
+    screen.should_contain_input('1')
+    screen.should_contain('model: 1')
+    screen.should_contain('event: 1')