Repository: zauberzeug/nicegui
Pull request #4683 (2025-05-01 15:41:01)

run.py: little refactoring to gain basic android compatibility
This PR refactors the initialization of the ThreadPoolExecutor and the ProcessPoolExecutor in `run.py` a little. As a result they get initialized on the first call to `io_bound` respectively `cpu_bound` on demand.

This is necessary to achieve basic android compatibility (#578) since named semaphores are not available on android which the named Executors use and therefor throw an exception on initialization.

With this change nicegui runs on android under the following conditions:
- calling `run.io_bound` and `run.cpu_bound` will throw exceptions (named semaphores not available)
- host must not equal '0.0.0.0' for now (because otherwise `welcome.py` makes an `io_bound` call)

`run.io_bound` and `run.cpu_bound` support might follow in a separate PR.

 With this PR the basic example app at https://github.com/jeff-dh/niceApp works on android (at least in my environment)

evnchn: The PR should have close to zero impact on existing code. 

Non-impacts: 
* `run.setup()` was never used outside of our code, and we said it was internal use only, so nobody can complain we dropped a function. 
<img width="300" alt="{2588BBEF-1C48-4F68-A6B7-D6BB27A4DEA8}" src="https://github.com/user-attachments/assets/006e8056-4db1-4b8c-b428-b4e569e301df" />

* `process_pool` and `thread_pool` are also both internal to `run.py`. As it makes no sense to interact to these two variables without using `run.cpu_bound` or `run.io_bound`, nobody can complain we change the type and make it lazy-intitialized. 

<img width="300" alt="{97EFB7D8-C9CF-45BF-95A9-728B680798E4}" src="https://github.com/user-attachments/assets/2bac708a-f239-452b-b78e-8301aeca8460" />

<img width="300" alt="{97EFB7D8-C9CF-45BF-95A9-728B680798E4}" src="https://github.com/user-attachments/assets/015ed34a-857f-4946-87d0-b14d41a08d1b" />


* New `tear_down()` behaves just like the old one when `process_pool` and `thread_pool` are both initialized, while the new functionality of selectively skipping the shutdown of either pools if they are `None` being a backwards-compatible superset functionality. 

Minor impacts:
* First call to `run.cpu_bound` and `run.io_bound` may be ever so slightly slower. Perhaps we can restore the `setup()` function, which initializes the pools around a try-except. Then, we can avoid dropping a function, and bring the performance back to pre-PR levels. 

Perhaps:
```py
def setup() -> None:
    """Setup the process pool and the thread pool. (For internal use only.)"""
    global process_pool  # pylint: disable=global-statement # noqa: PLW0603
    global thread_pool  # pylint: disable=global-statement # noqa: PLW0603
    try:
        if process_pool is not None:
            process_pool = ProcessPoolExecutor()
    except Exception:
        pass
    try:
        if thread_pool is not None:
            thread_pool = ThreadPoolExecutor()
    except Exception:
        pass
```

@jeff-dh see what you think? I have even beefed up the logic to allow `run.setup()` to be ran multiple times, just in case for Android, the `ProcessPoolExecutor` is not available on boot but somehow it is after we do some fixing. 
jeff-dh: @evnchn I completely agree with you. I would use a single `try except` because if one fails I assume the second will also fail. you could also wrap it in `if 'android' in platform.uname().release`.

To add `io_bound` and `cpu_bound` support, I assume we might end up with some sort of platform independent replacement or wrapper for the executor pools or even the whole `run` module (which only consists of `io_bound` and `cpu_bound` + deps, right?). For me it's hard to predict how that's gonna turn out in detail and what would be the best preparation for it now.
evnchn: > I would use a single `try except` because if one fails I assume the second will also fail.

It is an assumption I am not willing to make, and I am cranking the code robustness up to 11 here. 

I also don't want to do `if 'android' in platform.uname().release`, since I can imagine that the benefit of this PR extending beyond Android which doesn't have `ProcessPoolExecutor` and/or `ThreadPoolExecutor` (think: MicroPython? Non-CPython Python implementations? Python on the browser? etc...)

---

We can think about how to re-add support for `io_bound` and `cpu_bound`. The purpose of this PR, I think, is explicitly that on Android, NiceGUI doesn't blow up, just like FastAPI. 
evnchn: Use the "Hide whitespaces" view to see how cleanly this PR is at doing its job. 

<img width="858" alt="{9E6658FB-3D2E-4307-859B-EABEB7F9B028}" src="https://github.com/user-attachments/assets/bc0ba35e-7084-4061-be31-3795ac81f01f" />


jeff-dh: By accident this turns out to have a side effect:

Since the setup function will cause the pool variables to be `None` this actually results in a call to `loop.run_in_executor(None,....)` and this DOES NOT crash, because:

>The executor argument should be an [concurrent.futures.Executor](https://docs.python.org/3/library/concurrent.futures.html#concurrent.futures.Executor) instance. The default executor is used if executor is None. The default executor can be set by [loop.set_default_executor()](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.set_default_executor), otherwise, a [concurrent.futures.ThreadPoolExecutor](https://docs.python.org/3/library/concurrent.futures.html#concurrent.futures.ThreadPoolExecutor) will be lazy-initialized and used by [run_in_executor()](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.run_in_executor) if needed.

https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.run_in_executor

This means we get the platform independent `io_bound` for free and the `cpu_bound` does "work" but is buggy (it's a thread that will block the GIL).

Btw: Please double check the setup function. I think the snippet proposed above has a bug, I assume it's supposed to be `if process_pool is None: process_pool = ....`
jeff-dh: > The default executor can be set by [loop.set_default_executor()](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.set_default_executor), otherwise, a [concurrent.futures.ThreadPoolExecutor](https://docs.python.org/3/library/concurrent.futures.html#concurrent.futures.ThreadPoolExecutor) will be lazy-initialized and used by [run_in_executor()](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.run_in_executor) if needed.

Even though I don't understand why "they" somehow manage to initialize a `ThreadPoolExecutor`?!? (assuming the docs are correct.... or is buildozer maybe applying a patch?)
evnchn: > it's supposed to be `if process_pool is None: process_pool = ...`

It sure is! 🤦‍♂️Guess I'm too tired!

> `loop.run_in_executor(None,....)` and this DOES NOT crash

Hmm?! Thought it would crash with `NoneType object has no attribute XXX`. Sorry for that. But check the below as **we must fix this.** 

> we get the platform independent io_bound for free and the cpu_bound does "work" but is buggy

We can't afford to earn this free platform-independent `io_bound` by changing how the existing `io_bound` works for who-knows-how-many existing users. Also a buggy cpu_bound is not acceptable either. **We must raise an exception when the corresponding pool is None for those two functions.** 

> I don't understand why "they" somehow manage to initialize a `ThreadPoolExecutor`?!?

So do I! Why does it work?

https://github.com/python/cpython/blob/b8633f9aca9b198e5592106b649389d638cbc620/Lib/asyncio/base_events.py#L884-L898

<img width="383" alt="{0F252D95-600A-4E3F-9C09-99DE9308F49A}" src="https://github.com/user-attachments/assets/58037a08-0985-460b-a3d4-0631e6039f58" />

I think the possibility of a patch from buildozer / python for android is likely. They may be using a custom event loop which handles run_in_executor in a special manner. 

---

I think: 

`run`: The good-ol
`run2`: Using the default executor for `io_bound`, and launch-our-own-process technique for `cpu_bound`
`run_asyncio`: Use `asyncio.to_thread` for `io_bound`, and launch-our-own-process technique for `cpu_bound`

Reference: https://github.com/zauberzeug/nicegui/discussions/578#discussioncomment-13007174


jeff-dh: > changing how the existing `io_bound` works for who-knows-how-many existing users. Also a buggy cpu_bound is not acceptable either. **We must raise an exception when the corresponding pool is None for those two functions.**

we only change the behavior if the initialization of the corresponding pool raises an `NotImplementedError`

>I think:
>
>run: The good-ol
>run2: Using the default executor for io_bound, and launch-our-own-process technique for cpu_bound
>run_asyncio: Use asyncio.to_thread for io_bound, and launch-our-own-process technique for cpu_bound

from my perspective this has one major drawback, all "legacy" code (e.g. `welcome.py`) needs to potentially be changed to be platform independent.

I know you don't like the android specific platform check, but for me it's common to have platform specific code / modules in some kind of a `#ifdef platform_is_blablub` guard. Maybe there's a better way to check which platform(s) we're running on / which modules we want to use.

```python
try:
    import run #good old unmodified run.py
except NotImplementedError:
    import run_asyncio as run
```

would probably work.
evnchn: First of all: This is **off-topic** since this PR regards to gain basic android compat only. Using whatever method, block `io_bound` and `cpu_bound` from running in this PR when the corresponding pools are None, and that wraps it up. 

@jeff-dh I highly recommend to open a separate PR regarding implementing `run.io_bound` and `run.cpu_bound`. It's getting messy in this PR. 

---

> all "legacy" code (e.g. welcome.py) needs to potentially be changed to be platform independent

But that is explicitly the point. I do actively want them to switchover to the platform independent version, because we can't guarantee whether the platform independent version will behave exactly the same as existing one. 

Consider: 
- `special_function()` works if you run it in a separate ThreadPoolExecutor, but grinds to a halt otherwise. 
- If they do the switchover, then all is good, same code on all platforms, so if it fails in Dev it fails in Prod. 
- If they don't do the switchover, perhaps the code works on Dev, but fails in Prod. 

---

However, I think the switchover can be eased, if we have `run.set_run_method(method)`, where `method` can be `created_executor`, `default_executor`, or `asyncio_to_thread`. Then, we redirect `run.io_bound` to `run.created_executor.io_bound`, `run.default_executor.io_bound`, or `run.asyncio_to_thread.io_bound`. 

(Inspired heavily by `loop.set_default_executor`, `loop.set_event_loop` and the likes. Note how `asyncio` remains cross platform compatible, despite the event loops being all different on Windows and Linux, with some event loops (notable `uvloop`) not running on Windows at all.)

So, for Android `run.io_bound` and `run.io_bound`: We may just need `run.set_run_method(asyncio_to_thread)`, which then applies to the code everywhere. 

We will make the `set_run_method` error out if it doesn't work. Since you can argue having default run method being `created_executor` is also a set operation, on incompatible platforms such as Android, we warn and promote the switch to platform independent versions (we can't error out, since that would break functionality altogether)
evnchn: @jeff-dh Can this work? I am way too tired to try this on Windows, let alone complete the rest and fiddle with Buildozer to test if this can work for Android. 

```py
import asyncio
import logging
import sys
import traceback
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
from functools import partial
from typing import Any, Callable, Optional, TypeVar

from typing_extensions import ParamSpec

from . import core, helpers

P = ParamSpec('P')
R = TypeVar('R')


class SubprocessException(Exception):
    """A picklable exception to represent exceptions raised in subprocesses."""

    def __init__(self, original_type, original_message, original_traceback) -> None:
        self.original_type = original_type
        self.original_message = original_message
        self.original_traceback = original_traceback
        super().__init__(f'{original_type}: {original_message}')

    def __reduce__(self):
        return (SubprocessException, (self.original_type, self.original_message, self.original_traceback))

    def __str__(self):
        return (f'Exception in subprocess:\n'
                f'  Type: {self.original_type}\n'
                f'  Message: {self.original_message}\n'
                f'  {self.original_traceback}')


def safe_callback(callback: Callable, *args, **kwargs) -> Any:
    """Run a callback; catch and wrap any exceptions that might occur."""
    try:
        return callback(*args, **kwargs)
    except Exception as e:
        # NOTE: we do not want to pass the original exception because it might be unpicklable
        raise SubprocessException(type(e).__name__, str(e), traceback.format_exc()) from None


async def _run(executor: Any, callback: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
    if core.app.is_stopping:
        return  # type: ignore  # the assumption is that the user's code no longer cares about this value
    try:
        loop = asyncio.get_running_loop()
        return await loop.run_in_executor(executor, partial(callback, *args, **kwargs))
    except RuntimeError as e:
        if 'cannot schedule new futures after shutdown' not in str(e):
            raise
    except asyncio.CancelledError:
        pass
    return  # type: ignore  # the assumption is that the user's code no longer cares about this value


class BaseIOBoundRunner:
    """A base class to run I/O-bound functions in a separate thread."""

    async def io_bound(self, callback: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
        raise NotImplementedError('This method should be overridden by subclasses')

    def _cleanup(self) -> None:
        """Cleanup resources."""
        pass


class DefaultIOBoundRunner:
    """A class to run I/O-bound functions in a separate thread."""

    def __init__(self) -> None:
        self._thread_pool = ThreadPoolExecutor()

    async def io_bound(self, callback: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
        return await _run(self._thread_pool, callback, *args, **kwargs)

    def _cleanup(self) -> None:
        kwargs = {'cancel_futures': True} if sys.version_info >= (3, 9) else {}
        self._thread_pool.shutdown(wait=True, **kwargs)


class BaseCPUBoundRunner:
    """A base class to run CPU-bound functions in a separate process."""

    async def cpu_bound(self, callback: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
        raise NotImplementedError('This method should be overridden by subclasses')

    def _cleanup(self) -> None:
        """Cleanup resources."""
        pass


class DefaultCPUBoundRunner:
    """A class to run CPU-bound functions in a separate process."""

    def __init__(self) -> None:
        self._process_pool = ProcessPoolExecutor()

    async def cpu_bound(self, callback: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
        return await _run(self._process_pool, callback, *args, **kwargs)

    def _cleanup(self) -> None:
        for p in self._process_pool._processes.values():  # pylint: disable=protected-access
            p.kill()
        kwargs = {'cancel_futures': True} if sys.version_info >= (3, 9) else {}
        self._process_pool.shutdown(wait=True, **kwargs)


cpu_bound_runner: Optional[BaseCPUBoundRunner] = None
io_bound_runner: Optional[BaseIOBoundRunner] = None


def setup(cpu_bound_runner_cls: Optional[type[BaseCPUBoundRunner]] = None,
          io_bound_runner_cls: Optional[type[BaseIOBoundRunner]] = None) -> None:
    """Setup the process pool and thread pool."""
    global cpu_bound_runner, io_bound_runner  # pylint: disable=global-statement # noqa: PLW0603

    try:
        cpu_bound_runner = (cpu_bound_runner_cls or DefaultCPUBoundRunner)()
    except Exception as e:
        logging.warning(f'Failed to create CPU-bound runner: {e}')

    try:
        io_bound_runner = (io_bound_runner_cls or DefaultIOBoundRunner)()
    except Exception as e:
        logging.warning(f'Failed to create I/O-bound runner: {e}')


async def cpu_bound(callback: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
    """Run a CPU-bound function in a separate process.

    `run.cpu_bound` needs to execute the function in a separate process.
    For this it needs to transfer the whole state of the passed function to the process (which is done with pickle).
    It is encouraged to create static methods (or free functions) which get all the data as simple parameters (eg. no class/ui logic)
    and return the result (instead of writing it in class properties or global variables).
    """
    if cpu_bound_runner is None:
        raise RuntimeError('CPU-bound runner not set up. Please call setup() first.')
    return await cpu_bound_runner.cpu_bound(callback, *args, **kwargs)


async def io_bound(callback: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
    """Run an I/O-bound function in a separate thread."""
    if io_bound_runner is None:
        raise RuntimeError('I/O-bound runner not set up. Please call setup() first.')
    return await io_bound_runner.io_bound(callback, *args, **kwargs)


def tear_down() -> None:
    """Kill all processes and threads."""
    if helpers.is_pytest():
        return
    if cpu_bound_runner:
        cpu_bound_runner._cleanup()
    if io_bound_runner:
        io_bound_runner._cleanup()
```

Patch:
diff --git a/CITATION.cff b/CITATION.cff
index 02d92715..1fe99063 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -8,8 +8,8 @@ authors:
     given-names: Rodja
     orcid: https://orcid.org/0009-0009-4735-6227
 title: "NiceGUI: Web-based user interfaces with Python. The nice way."
-version: 2.15.0
-date-released: "2025-04-16"
+version: 2.16.1
+date-released: "2025-05-05"
 url: https://github.com/zauberzeug/nicegui
 doi: 10.5281/zenodo.7785516
 identifiers:
diff --git a/nicegui/background_tasks.py b/nicegui/background_tasks.py
index 7f39f4eb..e8da51a2 100644
--- a/nicegui/background_tasks.py
+++ b/nicegui/background_tasks.py
@@ -54,7 +54,10 @@ F = TypeVar('F', bound=Callable)
 
 
 def await_on_shutdown(func: F) -> F:
-    """Tag a coroutine function so tasks created from it won't be cancelled during shutdown."""
+    """Tag a coroutine function so tasks created from it won't be cancelled during shutdown.
+
+    *Added in version 2.16.0*
+    """
     functions_awaited_on_shutdown.add(func)
     return func
 
diff --git a/nicegui/element.py b/nicegui/element.py
index 3bfbe96c..73cbf31a 100644
--- a/nicegui/element.py
+++ b/nicegui/element.py
@@ -534,5 +534,8 @@ class Element(Visibility):
 
     @property
     def html_id(self) -> str:
-        """The ID of the element in the HTML DOM."""
+        """The ID of the element in the HTML DOM.
+
+        *Added in version 2.16.0*
+        """
         return f'c{self.id}'
diff --git a/nicegui/elements/codemirror.py b/nicegui/elements/codemirror.py
index 47c4f146..03daeffd 100644
--- a/nicegui/elements/codemirror.py
+++ b/nicegui/elements/codemirror.py
@@ -1,6 +1,6 @@
-from itertools import zip_longest
+from itertools import accumulate, zip_longest
 from pathlib import Path
-from typing import List, Literal, Optional, Tuple, cast, get_args
+from typing import List, Literal, Optional, get_args
 
 from nicegui.elements.mixins.disableable_element import DisableableElement
 from nicegui.elements.mixins.value_element import ValueElement
@@ -338,12 +338,12 @@ class CodeMirror(ValueElement, DisableableElement, component='codemirror.js', de
 
 def _apply_change_set(doc, sections: List[int], inserted: List[List[str]]) -> str:
     # based on https://github.com/codemirror/state/blob/main/src/change.ts
-    assert sum(sections[::2]) == len(doc), 'Cannot apply change set to document due to length mismatch'
-    pos = 0
+    old_lengths = sections[::2]
+    new_lengths = sections[1::2]
+    end_positions = accumulate(old_lengths)
     joined_inserts = ('\n'.join(ins) for ins in inserted)
-    for section in zip_longest(sections[::2], sections[1::2], joined_inserts, fillvalue=''):
-        old_len, new_len, ins = cast(Tuple[int, int, str], section)
-        if new_len >= 0:
-            doc = doc[:pos] + ins + doc[pos + old_len:]
-        pos += old_len
-    return doc
+    assert sum(old_lengths) == len(doc), 'Cannot apply change set to document due to length mismatch'
+    return ''.join(
+        doc[pos-old_len:pos] if new_len == -1 else ins  # type: ignore
+        for pos, old_len, new_len, ins in zip_longest(end_positions, old_lengths, new_lengths, joined_inserts, fillvalue='')
+    )
diff --git a/nicegui/helpers.py b/nicegui/helpers.py
index b9c3a79c..a408d7bd 100644
--- a/nicegui/helpers.py
+++ b/nicegui/helpers.py
@@ -72,7 +72,7 @@ def is_port_open(host: str, port: int) -> bool:
         sock.close()
 
 
-def schedule_browser(host: str, port: int) -> Tuple[threading.Thread, threading.Event]:
+def schedule_browser(protocol: str, host: str, port: int) -> Tuple[threading.Thread, threading.Event]:
     """Wait non-blockingly for the port to be open, then start a webbrowser.
 
     This function launches a thread in order to be non-blocking.
@@ -88,15 +88,15 @@ def schedule_browser(host: str, port: int) -> Tuple[threading.Thread, threading.
     """
     cancel = threading.Event()
 
-    def in_thread(host: str, port: int) -> None:
+    def in_thread(protocol: str, host: str, port: int) -> None:
         while not is_port_open(host, port):
             if cancel.is_set():
                 return
             time.sleep(0.1)
-        webbrowser.open(f'http://{host}:{port}/')
+        webbrowser.open(f'{protocol}://{host}:{port}/')
 
     host = host if host != '0.0.0.0' else '127.0.0.1'
-    thread = threading.Thread(target=in_thread, args=(host, port), daemon=True)
+    thread = threading.Thread(target=in_thread, args=(protocol, host, port), daemon=True)
     thread.start()
     return thread, cancel
 
diff --git a/nicegui/run.py b/nicegui/run.py
index 33d3e2d6..80a88d0e 100644
--- a/nicegui/run.py
+++ b/nicegui/run.py
@@ -1,4 +1,5 @@
 import asyncio
+import logging
 import sys
 import traceback
 from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
@@ -19,7 +20,10 @@ R = TypeVar('R')
 def setup() -> None:
     """Setup the process pool. (For internal use only.)"""
     global process_pool  # pylint: disable=global-statement # noqa: PLW0603
-    process_pool = ProcessPoolExecutor()
+    try:
+        process_pool = ProcessPoolExecutor()
+    except NotImplementedError:
+        logging.warning('Failed to initialize ProcessPoolExecutor')
 
 
 class SubprocessException(Exception):
@@ -72,6 +76,9 @@ async def cpu_bound(callback: Callable[P, R], *args: P.args, **kwargs: P.kwargs)
     It is encouraged to create static methods (or free functions) which get all the data as simple parameters (eg. no class/ui logic)
     and return the result (instead of writing it in class properties or global variables).
     """
+    if process_pool is None:
+        raise RuntimeError('Process pool not set up.')
+
     return await _run(process_pool, safe_callback, callback, *args, **kwargs)
 
 
@@ -84,9 +91,10 @@ def tear_down() -> None:
     """Kill all processes and threads."""
     if helpers.is_pytest():
         return
-    assert process_pool is not None
-    for p in process_pool._processes.values():  # pylint: disable=protected-access
-        p.kill()
+
     kwargs = {'cancel_futures': True} if sys.version_info >= (3, 9) else {}
-    process_pool.shutdown(wait=True, **kwargs)
+    if process_pool is not None:
+        for p in process_pool._processes.values():  # pylint: disable=protected-access
+            p.kill()
+        process_pool.shutdown(wait=True, **kwargs)
     thread_pool.shutdown(wait=False, **kwargs)
diff --git a/nicegui/ui_run.py b/nicegui/ui_run.py
index b4e9e839..99407d39 100644
--- a/nicegui/ui_run.py
+++ b/nicegui/ui_run.py
@@ -184,12 +184,18 @@ def run(*,
     assert host is not None
     assert port is not None
 
+    if kwargs.get('ssl_certfile') and kwargs.get('ssl_keyfile'):
+        protocol = 'https'
+    else:
+        protocol = 'http'
+
     # NOTE: We save host and port in environment variables so the subprocess started in reload mode can access them.
     os.environ['NICEGUI_HOST'] = host
     os.environ['NICEGUI_PORT'] = str(port)
+    os.environ['NICEGUI_PROTOCOL'] = protocol
 
     if show:
-        helpers.schedule_browser(host, port)
+        helpers.schedule_browser(protocol, host, port)
 
     def split_args(args: str) -> List[str]:
         return [a.strip() for a in args.split(',')]
diff --git a/nicegui/welcome.py b/nicegui/welcome.py
index 7936c810..31cf92f0 100644
--- a/nicegui/welcome.py
+++ b/nicegui/welcome.py
@@ -17,12 +17,13 @@ async def collect_urls() -> None:
     """Print a welcome message with URLs to access the NiceGUI app."""
     host = os.environ.get('NICEGUI_HOST')
     port = os.environ.get('NICEGUI_PORT')
-    if not host or not port:
+    protocol = os.environ.get('NICEGUI_PROTOCOL')
+    if not host or not port or not protocol:
         return
     ips = set((await run.io_bound(_get_all_ips)) if host == '0.0.0.0' else [])
     ips.discard('127.0.0.1')
     sorted_ips = ['localhost' if host == '0.0.0.0' else host, *sorted(ips)]
-    urls = [(f'http://{ip}:{port}' if port != '80' else f'http://{ip}') for ip in sorted_ips]
+    urls = [(f'{protocol}://{ip}:{port}' if port != '80' else f'{protocol}://{ip}') for ip in sorted_ips]
     core.app.urls.update(urls)
     if len(urls) >= 2:
         urls[-1] = 'and ' + urls[-1]
diff --git a/pyproject.toml b/pyproject.toml
index 99b55d9d..377b9e3c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "nicegui"
-version = "2.15.0-dev"
+version = "2.16.1-dev"
 description = "Create web-based user interfaces with Python. The nice way."
 authors = ["Zauberzeug GmbH <info@zauberzeug.com>"]
 license = "MIT"
diff --git a/website/documentation/content/context_menu_documentation.py b/website/documentation/content/context_menu_documentation.py
index 290f7240..1ac26539 100644
--- a/website/documentation/content/context_menu_documentation.py
+++ b/website/documentation/content/context_menu_documentation.py
@@ -13,4 +13,22 @@ def main_demo() -> None:
             ui.menu_item('Reset', auto_close=False)
 
 
+@doc.demo('Context menus with dynamic content', '''
+    To show a context menu with content that changes dynamically, e.g. based on the position of the mouse,
+    it is recommended to re-use the same context menu instance.
+    This demo shows how to clear the context menu and add new items to it.
+''')
+def update_context_menu() -> None:
+    from nicegui import events
+
+    def update_menu(e: events.MouseEventArguments) -> None:
+        context_menu.clear()
+        with context_menu:
+            ui.menu_item(f'Add circle at ({e.image_x:.0f}, {e.image_y:.0f})')
+
+    source = 'https://picsum.photos/id/377/640/360'
+    with ui.interactive_image(source, on_mouse=update_menu, events=['contextmenu']):
+        context_menu = ui.context_menu()
+
+
 doc.reference(ui.context_menu)
diff --git a/website/documentation/content/markdown_documentation.py b/website/documentation/content/markdown_documentation.py
index 28d7532b..db25c532 100644
--- a/website/documentation/content/markdown_documentation.py
+++ b/website/documentation/content/markdown_documentation.py
@@ -75,4 +75,21 @@ def markdown_new_content():
     ui.button('Change Content', on_click=lambda: markdown.set_content('This is new content'))
 
 
+@doc.demo('Styling elements inside Markdown', '''
+    To style HTML elements inside a `ui.markdown` element, you can add custom CSS rules for the "nicegui-markdown" class.
+''')
+def markdown_styling():
+    ui.add_css('''
+        .nicegui-markdown a {
+            color: orange;
+            text-decoration: none;
+        }
+        .nicegui-markdown a:hover {
+            color: orange;
+            text-decoration: underline;
+        }
+    ''')
+    ui.markdown('This is a [link](https://example.com).')
+
+
 doc.reference(ui.markdown)
diff --git a/website/documentation/content/section_configuration_deployment.py b/website/documentation/content/section_configuration_deployment.py
index ea65be1d..83e85a60 100644
--- a/website/documentation/content/section_configuration_deployment.py
+++ b/website/documentation/content/section_configuration_deployment.py
@@ -122,13 +122,13 @@ def env_var_demo():
 @doc.demo('Background Tasks', '''
     `background_tasks.create()` allows you to run an async function in the background and return a task object.
     By default the task will be automatically cancelled during shutdown.
-    You can prevent this by using the `@background_tasks.await_on_shutdown` decorator.
+    You can prevent this by using the `@background_tasks.await_on_shutdown` decorator (added in version 2.16.0).
     This is useful for tasks that need to be completed even when the app is shutting down.
 ''')
 def background_tasks_demo():
-    from nicegui import background_tasks
+    # import aiofiles
     import asyncio
-    import aiofiles
+    from nicegui import background_tasks
 
     results = {'answer': '?'}
 
@@ -218,7 +218,8 @@ doc.text('', '''
 
     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()`:
+    relevant [options](https://www.uvicorn.org/#command-line-options) to `ui.run()`.
+    If both a certificate and key file are provided, the application will automatically be served over HTTPS:
 ''')
 
 
diff --git a/website/sponsors.json b/website/sponsors.json
index 67cf598a..c73fcb89 100644
--- a/website/sponsors.json
+++ b/website/sponsors.json
@@ -1,5 +1,5 @@
 {
   "top": [],
-  "total": 20,
-  "contributors": 154
+  "total": 18,
+  "contributors": 156
 }


Test patch:
diff --git a/tests/test_codemirror.py b/tests/test_codemirror.py
index 97d7ba8d..64f5eac9 100644
--- a/tests/test_codemirror.py
+++ b/tests/test_codemirror.py
@@ -42,3 +42,4 @@ def test_change_set():
     assert _apply_change_set('X', [1, -1, 0, 1], [[], ['Y']]) == 'XY'
     assert _apply_change_set('Hello', [5, -1, 0, 8], [[], [', world!']]) == 'Hello, world!'
     assert _apply_change_set('Hello, world!', [5, -1, 7, 0, 1, -1], []) == 'Hello!'
+    assert _apply_change_set('Hello, hello!', [2, -1, 3, 1, 4, -1, 3, 1, 1, -1], [[], ['y'], [], ['y']]) == 'Hey, hey!'
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
index e24efef2..01c1ad7e 100644
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -39,7 +39,7 @@ def test_schedule_browser(monkeypatch):
         sock.bind(('127.0.0.1', 0))
         host, port = sock.getsockname()
 
-        _, cancel_event = helpers.schedule_browser(host, port)
+        _, cancel_event = helpers.schedule_browser('http', host, port)
 
         try:
             # port bound, but not opened yet
@@ -70,7 +70,7 @@ def test_canceling_schedule_browser(monkeypatch):
     # ... and close it so schedule_browser does not launch the browser
     sock.close()
 
-    thread, cancel_event = helpers.schedule_browser(host, port)
+    thread, cancel_event = helpers.schedule_browser('http', host, port)
     time.sleep(0.2)
     cancel_event.set()
     time.sleep(0.2)