Repository: zauberzeug/nicegui
Pull request #3257 (2024-06-21 14:27:35)

Allow changing grid parameters of `ui.scene`
Inspired by #3254, this PR extends the `grid` parameter of `ui.scene` to accept a tuple of size and number of divisions, like [Three.js' GridHelper](https://threejs.org/docs/#api/en/helpers/GridHelper).


Patch:
diff --git a/examples/nginx_https/README.md b/examples/nginx_https/README.md
new file mode 100644
index 00000000..95816b8c
--- /dev/null
+++ b/examples/nginx_https/README.md
@@ -0,0 +1,49 @@
+# Serving an App with HTTPS Encryption behind a Reverse Proxy (NGINX)
+
+This example shows how to serve NiceGUI with HTTPS encryption behind NGINX.
+For running the app under a subpath, have a look at https://github.com/zauberzeug/nicegui/blob/main/examples/nginx_subpath.
+
+## Try Out
+
+1. Create the `certs/` directory:
+
+   ```bash
+   mkdir certs
+   ```
+
+2. Generate and self-sign an SSL certificate for "localhost":
+
+   ```bash
+   openssl req -x509 -out certs/localhost.crt -keyout certs/localhost.key -newkey rsa:2048 -nodes -sha256 -subj '/CN=localhost' -extensions EXT -config <( printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth")
+   ```
+
+3. Run the containerized app:
+
+   ```bash
+   docker compose up
+   ```
+
+4. Try to access http://localhost (without the "s").
+   You will be automatically redirected to the HTTPS version.
+
+5. Depending on your browser, you should typically receive a warning that the certificate authority is invalid.
+   This is perfectly normal as we issued and self-signed a simple certificate for demo use locally.
+   So proceed anyway, e.g. on Chrome, click on "Advanced" > "Proceed to localhost (unsafe)".
+
+6. Note the "Hello World" message from the app accessed with encrypted connection.
+
+## Deploy in production in your self-hosted NGINX
+
+- For production, you will need your own domain and a proper SSL certificate issued by a recognized Certificate Authority (CA).
+  You can consider the free CA [Let's Encrypt](https://letsencrypt.org/) provided by the Internal Security Research Group, and use their [Certbox](https://certbot.eff.org/instructions) tool to generate the certificates for your own domain.
+
+- If you already have an NGINX server running and want to add your NiceGUI app,
+  you can reuse a stripped-down version of the nginx.conf file,
+  [nginx_site.conf](https://github.com/zauberzeug/nicegui/blob/main/example/nginx_https/nginx_site.conf).
+
+```bash
+sudo cp nginx_site.conf /etc/nginx/sites-available/my_nicegui_app.conf
+sudo nano /etc/nginx/sites-available/my_nicegui_app.conf # customize your domain, certificates, port number, etc.
+sudo ln -s /etc/nginx/sites-available/mynicegui_app.conf /etc/nginx/sites-enabled
+sudo systemctl reload nginx
+```
diff --git a/examples/nginx_https/app/main.py b/examples/nginx_https/app/main.py
new file mode 100755
index 00000000..a5f38edf
--- /dev/null
+++ b/examples/nginx_https/app/main.py
@@ -0,0 +1,6 @@
+#!/usr/bin/env python3
+from nicegui import ui
+
+ui.label('Hello HTTPS encrypted world')
+
+ui.run()
diff --git a/examples/nginx_https/docker-compose.yml b/examples/nginx_https/docker-compose.yml
new file mode 100644
index 00000000..a8321bb1
--- /dev/null
+++ b/examples/nginx_https/docker-compose.yml
@@ -0,0 +1,13 @@
+services:
+  app:
+    image: zauberzeug/nicegui:1.4.27
+    volumes:
+      - ./app:/app # mount local app directory
+  proxy:
+    image: nginx:1.27.0-alpine
+    ports:
+      - "80:80" # map internal port 80 to external port 80
+      - "443:443" # map internal port 443 to external port 443
+    volumes:
+      - ./nginx.conf:/etc/nginx/nginx.conf # use custom nginx config
+      - ./certs:/certs
diff --git a/examples/nginx_https/nginx.conf b/examples/nginx_https/nginx.conf
new file mode 100644
index 00000000..89578403
--- /dev/null
+++ b/examples/nginx_https/nginx.conf
@@ -0,0 +1,53 @@
+worker_processes  1;
+
+events {
+    worker_connections  1024;
+}
+
+
+http {
+    include       mime.types;
+    default_type  application/octet-stream;
+
+
+    sendfile        on;
+    keepalive_timeout  65;
+
+    server {
+        listen 80;
+        listen [::]:80;
+        server_name _;
+        resolver 127.0.0.11; # Specific to running nginx proxy in docker
+                             # See https://github.com/docker/compose/issues/3412
+
+        # Redirect all HTTP requests to HTTPS
+        return 301 https://$host$request_uri;
+    }
+
+    server {
+        listen 443 ssl;
+        listen [::]:443 ssl;
+        http2 on;
+        server_name _;
+        resolver 127.0.0.11; # Specific to running nginx proxy in docker
+                             # See https://github.com/docker/compose/issues/3412
+
+        # SSL configuration
+        ssl_certificate /certs/localhost.crt;
+        ssl_certificate_key /certs/localhost.key;
+        ssl_session_timeout 1d;
+
+        # Proxy pass to app:8080
+        location / {
+            proxy_pass http://app:8080;
+            proxy_redirect http://app:8080/ /;
+            proxy_set_header Host $host;
+            proxy_set_header X-Real-IP $remote_addr;
+            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+            proxy_set_header X-Forwarded-Proto $scheme;
+            proxy_http_version 1.1;
+            proxy_set_header Upgrade $http_upgrade;
+            proxy_set_header Connection "Upgrade";
+        }
+    }
+}
diff --git a/examples/nginx_https/nginx_site.conf b/examples/nginx_https/nginx_site.conf
new file mode 100644
index 00000000..c356376a
--- /dev/null
+++ b/examples/nginx_https/nginx_site.conf
@@ -0,0 +1,32 @@
+server {
+    listen 80;
+    listen [::]:80;
+    server_name my-domain.com;
+
+    # Redirect all HTTP requests to HTTPS
+    return 301 https://$host$request_uri;
+}
+
+server {
+    listen 443 ssl;
+    listen [::]:443 ssl;
+    http2 on;
+    server_name my-domain.com;
+
+    # SSL configuration
+    ssl_certificate /etc/letsencrypt/live/my-domain.com/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/my-domain.com/privkey.pem;
+    ssl_session_timeout 1d;
+
+    # Proxy pass to localhost:8080
+    location / {
+        proxy_pass http://localhost:8080;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "Upgrade";
+    }
+}
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/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 c3814a69..cfb9d52b 100644
--- a/website/documentation/content/section_configuration_deployment.py
+++ b/website/documentation/content/section_configuration_deployment.py
@@ -134,9 +134,33 @@ doc.text('', '''
     There are other handy features in the Docker image like non-root user execution and signal pass-through.
     For more details we recommend to have a look at our [Docker example](https://github.com/zauberzeug/nicegui/tree/main/examples/docker_image).
 
-    You can provide SSL certificates directly using [FastAPI](https://fastapi.tiangolo.com/deployment/https/).
+    To serve your application with [HTTPS](https://fastapi.tiangolo.com/deployment/https/) encryption, you can provide SSL certificates in multiple ways.
+    For instance, you can directly provide your certificates to [Uvicorn](https://www.uvicorn.org/), which NiceGUI is based on, by passing the
+    relevant [options](https://www.uvicorn.org/#command-line-options) to `ui.run()`:
+''')
+
+
+@doc.ui
+def uvicorn_ssl():
+    with python_window('main.py', classes='max-w-lg w-full'):
+        ui.markdown('''
+            ```python
+            from nicegui import ui
+
+            ui.run(
+                port=443,
+                ssl_certfile="<path_to_certfile>",
+                ssl_keyfile="<path_to_keyfile>",
+            )
+            ```
+        ''')
+
+
+doc.text('', '''
     In production we also like using reverse proxies like [Traefik](https://doc.traefik.io/traefik/) or [NGINX](https://www.nginx.com/) to handle these details for us.
-    See our development [docker-compose.yml](https://github.com/zauberzeug/nicegui/blob/main/docker-compose.yml) as an example.
+    See our development [docker-compose.yml](https://github.com/zauberzeug/nicegui/blob/main/docker-compose.yml) as an example based on traefik or
+    [this example nginx.conf file](https://github.com/zauberzeug/nicegui/blob/main/examples/nginx_https/nginx.conf) showing how NGINX can be used to handle the SSL certificates and
+    reverse proxy to your NiceGUI app.
 
     You may also have a look at [our demo for using a custom FastAPI app](https://github.com/zauberzeug/nicegui/tree/main/examples/fastapi).
     This will allow you to do very flexible deployments as described in the [FastAPI documentation](https://fastapi.tiangolo.com/deployment/).
diff --git a/website/examples.py b/website/examples.py
index f263bb18..0b45bc42 100644
--- a/website/examples.py
+++ b/website/examples.py
@@ -66,4 +66,5 @@ examples: List[Example] = [
     Example('Websockets', 'use [websockets library](https://websockets.readthedocs.io/) to start a websocket server'),
     Example('Audio Recorder', 'Record audio, play it back or download it'),
     Example('ZeroMQ', 'Simple ZeroMQ PUSH/PULL server and client'),
+    Example('NGINX HTTPS', 'Use NGINX to serve a NiceGUI app with HTTPS'),
 ]


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')