Repository: zauberzeug/nicegui (python)

Issue:
`ui.mermaid` renders incorrectly within `ui.dialog`
### First Check

- [x] I added a very descriptive title here.
- [x] This is not a Q&A. I am sure something is wrong with NiceGUI or its documentation.
- [x] I used the GitHub search to find a similar [issue](https://github.com/zauberzeug/nicegui/issues) and came up empty.

### Example Code

```python
with ui.dialog(value=True), ui.card():
    ui.mermaid('graph TD; A --> B')
```

### Description

I'm expecting to see this:
<img width="152" alt="Image" src="https://github.com/user-attachments/assets/ca1cb82f-2e55-4e6d-b4bb-178a9f6f1653" />

But I'm getting this:
<img width="150" alt="Image" src="https://github.com/user-attachments/assets/da7d9a7f-6feb-48af-82bc-d3c2e8462ef8" />

### NiceGUI Version

2.13.0

### Python Version

3.8

### Browser

Chrome

### Operating System

macOS

### Additional Context

This problem was originally reported by @ntmarkus as part of issue #4502. After quite some discussion I isolated this aspect as a separate issue.

A very suboptimal workaround is to add a delay:
```py
with ui.dialog(value=True), ui.card():
    ui.timer(2.0, lambda: ui.mermaid('graph TD; A --> B'), once=True)
```

Pull request:
Use the Mermaid API directly to fix nested diagram inside `ui.dialog`
This PR fix #4518 magically by invoking the Mermaid API exactly as prescribed here: 

https://mermaid.js.org/config/usage.html#api-usage

I think the commit wraps it up very well: 

> Dear mermaid: we'll call API ourselves thankyouverymuch

Their code: 
```js
  element = document.querySelector('#graphDiv');
  const graphDefinition = 'graph TB\na-->b';
  const { svg, bindFunctions } = await mermaid.render('graphDiv', graphDefinition);
  element.innerHTML = svg;
  // This can also be written as `bindFunctions?.(element);` using the `?` shorthand.
  if (bindFunctions) {
    bindFunctions(element);
  }
```

My code:
```js
let element = queue.shift(); // same mindset
let innertext = this.content; // mine's from Vue
const { svg, bindFunctions } = await mermaid.render('graphDiv', innertext); // literally the same except variable names
element.innerHTML = svg; // literally the same
if (bindFunctions) {
   bindFunctions(element); // literally the same
}
```

If I had to guess, the reason it was fixed, is because '#graphDiv' does not exist, and so Mermaid just "assumes full size" and renders the proper diagram without being pinched to absolutely tiny. 

Note that there are a couple of shortcomings which I haven't tested / haven't fixed, so don't merge just yet. 

- [x] what happens when we change the content? Does the mermaid diagram update appropriately? 
- [x] As of how, if there is syntax error, we lose the good-ol "Mermaid Bomb" error screen. Should we have better means? 
- [x] Are the Mermaid config respected (`{'securityLevel': 'loose', ...}` - allow running JavaScript when a node is clicked; `{'logLevel': 'info', ...}` - log debug info to the console) respected under this approach? 
- [x] Does this PR impact existing code? If so, how much? 
- [x] Passing 'graphDiv' when '#graphDiv' doesn't exist seems kinda sketchy. Are we absolutely sure that's the way to go?

---

![image](https://github.com/user-attachments/assets/f9eb99ef-5072-4af8-bc32-5b6ea1f09497)

Perfect diagram, despite disable cache is false / disable cache is on and no network throttle. 

Patch:
diff --git a/CITATION.cff b/CITATION.cff
index 7b479d2e..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.16.0
-date-released: "2025-05-02"
+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/elements/codemirror.py b/nicegui/elements/codemirror.py
index 47c4f146..368d871f 100644
--- a/nicegui/elements/codemirror.py
+++ b/nicegui/elements/codemirror.py
@@ -1,6 +1,6 @@
-from itertools import zip_longest
+from itertools import accumulate, chain, repeat
 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
@@ -281,7 +281,11 @@ class CodeMirror(ValueElement, DisableableElement, component='codemirror.js', de
         :param line_wrapping: whether to wrap lines (default: `False`)
         :param highlight_whitespace: whether to highlight whitespace (default: `False`)
         """
-        super().__init__(value=value, on_value_change=on_change)
+        super().__init__(value=value, on_value_change=self._update_codepoints)
+        self._codepoints = b''
+        self._update_codepoints()
+        if on_change is not None:
+            super().on_value_change(on_change)
         self.add_resource(Path(__file__).parent / 'lib' / 'codemirror')
 
         self._props['language'] = language
@@ -333,17 +337,39 @@ class CodeMirror(ValueElement, DisableableElement, component='codemirror.js', de
 
     def _event_args_to_value(self, e: GenericEventArguments) -> str:
         """The event contains a change set which is applied to the current value."""
-        return _apply_change_set(self.value, e.args['sections'], e.args['inserted'])
+        return self._apply_change_set(e.args['sections'], e.args['inserted'])
 
+    @staticmethod
+    def _encode_codepoints(doc: str) -> bytes:
+        return b''.join(b'\0\1' if ord(c) > 0xFFFF else b'\1' for c in doc)
 
-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
-    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
+    def _update_codepoints(self) -> None:
+        """Update `self._codepoints` as a concatenation of "1" for code points <=0xFFFF and "01" for code points >0xFFFF.
+
+        This captures how many Unicode code points are encoded by each UTF-16 code unit.
+        This is used to convert JavaScript string indices to Python by summing `self._codepoints` up to the JavaScript index.
+        """
+        if not self._send_update_on_value_change:
+            return  # the update is triggered by the user and codepoints are updated incrementally
+        self._codepoints = self._encode_codepoints(self.value or '')
+
+    def _apply_change_set(self, sections: List[int], inserted: List[List[str]]) -> str:
+        document = self.value or ''
+        old_lengths = sections[::2]
+        new_lengths = sections[1::2]
+        end_positions = accumulate(old_lengths)
+        document_parts: List[str] = []
+        codepoint_parts: List[bytes] = []
+        for end, old_len, new_len, insert in zip(end_positions, old_lengths, new_lengths, chain(inserted, repeat([]))):
+            if new_len == -1:
+                start = end - old_len
+                py_start = self._codepoints[:start].count(1)
+                py_end = py_start + self._codepoints[start:end].count(1)
+                document_parts.append(document[py_start:py_end])
+                codepoint_parts.append(self._codepoints[start:end])
+            else:
+                joined_insert = '\n'.join(insert)
+                document_parts.append(joined_insert)
+                codepoint_parts.append(self._encode_codepoints(joined_insert))
+        self._codepoints = b''.join(codepoint_parts)
+        return ''.join(document_parts)
diff --git a/nicegui/elements/leaflet.py b/nicegui/elements/leaflet.py
index 2a5bbfb9..53926fa0 100644
--- a/nicegui/elements/leaflet.py
+++ b/nicegui/elements/leaflet.py
@@ -14,8 +14,10 @@ from .leaflet_layer import Layer
 class Leaflet(Element, component='leaflet.js', default_classes='nicegui-leaflet'):
     # pylint: disable=import-outside-toplevel
     from .leaflet_layers import GenericLayer as generic_layer
+    from .leaflet_layers import ImageOverlay as image_overlay
     from .leaflet_layers import Marker as marker
     from .leaflet_layers import TileLayer as tile_layer
+    from .leaflet_layers import VideoOverlay as video_overlay
     from .leaflet_layers import WmsLayer as wms_layer
 
     center = binding.BindableProperty(lambda sender, value: cast(Leaflet, sender).set_center(value))
diff --git a/nicegui/elements/leaflet_layers.py b/nicegui/elements/leaflet_layers.py
index 651450e5..78290588 100644
--- a/nicegui/elements/leaflet_layers.py
+++ b/nicegui/elements/leaflet_layers.py
@@ -1,5 +1,5 @@
 from dataclasses import dataclass, field
-from typing import Any, Dict, List, Tuple
+from typing import Any, Dict, List, Tuple, Union
 
 from typing_extensions import Self
 
@@ -43,6 +43,32 @@ class WmsLayer(Layer):
         }
 
 
+@dataclass(**KWONLY_SLOTS)
+class ImageOverlay(Layer):
+    url: str
+    bounds: List[List[float]]
+    options: Dict = field(default_factory=dict)
+
+    def to_dict(self) -> Dict:
+        return {
+            'type': 'imageOverlay',
+            'args': [self.url, self.bounds, self.options],
+        }
+
+
+@dataclass(**KWONLY_SLOTS)
+class VideoOverlay(Layer):
+    url: Union[str, List[str]]
+    bounds: List[List[float]]
+    options: Dict = field(default_factory=dict)
+
+    def to_dict(self) -> Dict:
+        return {
+            'type': 'videoOverlay',
+            'args': [self.url, self.bounds, self.options],
+        }
+
+
 @dataclass(**KWONLY_SLOTS)
 class Marker(Layer):
     latlng: Tuple[float, float]
diff --git a/nicegui/elements/mermaid.js b/nicegui/elements/mermaid.js
index 3773a45b..8634ab2b 100644
--- a/nicegui/elements/mermaid.js
+++ b/nicegui/elements/mermaid.js
@@ -1,8 +1,5 @@
 import mermaid from "mermaid";
 
-let is_running = false;
-const queue = [];
-
 export default {
   template: `<div></div>`,
   data: () => ({
@@ -24,20 +21,18 @@ export default {
     async update(content) {
       if (this.last_content === content) return;
       this.last_content = content;
-      this.$el.innerHTML = content;
-      this.$el.removeAttribute("data-processed");
-      queue.push(this.$el);
-      if (is_running) return;
-      is_running = true;
-      while (queue.length) {
-        try {
-          await mermaid.run({ nodes: [queue.shift()] });
-        } catch (error) {
-          console.error(error);
-          this.$emit("error", error);
-        }
+      try {
+        const { svg, bindFunctions } = await mermaid.render(this.$el.id + "_mermaid", content);
+        this.$el.innerHTML = svg;
+        bindFunctions?.(this.$el);
+      } catch (error) {
+        const { svg, bindFunctions } = await mermaid.render(this.$el.id + "_mermaid", "error");
+        this.$el.innerHTML = svg;
+        bindFunctions?.(this.$el);
+        const mermaidErrorFormat = { str: error.message, message: error.message, hash: error.name, error };
+        console.error(mermaidErrorFormat);
+        this.$emit("error", mermaidErrorFormat);
       }
-      is_running = false;
     },
   },
   props: {
diff --git a/nicegui/functions/refreshable.py b/nicegui/functions/refreshable.py
index a08a3734..6806fa92 100644
--- a/nicegui/functions/refreshable.py
+++ b/nicegui/functions/refreshable.py
@@ -160,7 +160,7 @@ def state(value: Any) -> Tuple[Any, Callable[[Any], None]]:
         if target.locals[index] == new_value:
             return
         target.locals[index] = new_value
-        target.refreshable.refresh()
+        target.refreshable.refresh(_instance=target.instance)
 
     target.next_index += 1
 
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/static/nicegui.css b/nicegui/static/nicegui.css
index 685f5d2f..7b83ae21 100644
--- a/nicegui/static/nicegui.css
+++ b/nicegui/static/nicegui.css
@@ -219,6 +219,9 @@
   width: 100%;
   height: 16rem;
 }
+.nicegui-leaflet video.leaflet-image-layer {
+  max-width: none; /* HACK: make the video show up */
+}
 .nicegui-log {
   padding: 0.5rem;
   scroll-padding-bottom: 0.5rem;
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 5263ef73..377b9e3c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "nicegui"
-version = "2.16.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/code_extraction.py b/website/documentation/code_extraction.py
new file mode 100644
index 00000000..b0f73e3f
--- /dev/null
+++ b/website/documentation/code_extraction.py
@@ -0,0 +1,33 @@
+import inspect
+import re
+from typing import Callable
+
+import isort
+
+UNCOMMENT_PATTERN = re.compile(r'^(\s*)# ?')
+
+
+def _uncomment(text: str) -> str:
+    return UNCOMMENT_PATTERN.sub(r'\1', text)  # NOTE: non-executed lines should be shown in the code examples
+
+
+def get_full_code(f: Callable) -> str:
+    """Get the full code of a function as a string."""
+    code = inspect.getsource(f).split('# END OF DEMO', 1)[0].strip().splitlines()
+    code = [line for line in code if not line.endswith('# HIDE')]
+    while not code[0].strip().startswith(('def', 'async def')):
+        del code[0]
+    del code[0]
+    if code[0].strip().startswith('"""'):
+        while code[0].strip() != '"""':
+            del code[0]
+        del code[0]
+    indentation = len(code[0]) - len(code[0].lstrip())
+    code = [line[indentation:] for line in code]
+    code = ['from nicegui import ui'] + [_uncomment(line) for line in code]
+    code = ['' if line == '#' else line for line in code]
+    if not code[-1].startswith('ui.run('):
+        code.append('')
+        code.append('ui.run()')
+    full_code = isort.code('\n'.join(code), no_sections=True, lines_after_imports=1)
+    return full_code
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/leaflet_documentation.py b/website/documentation/content/leaflet_documentation.py
index 46e27e60..9b358990 100644
--- a/website/documentation/content/leaflet_documentation.py
+++ b/website/documentation/content/leaflet_documentation.py
@@ -77,6 +77,32 @@ def move_markers() -> None:
     ui.button('Move marker', on_click=lambda: marker.move(51.51, -0.09))
 
 
+@doc.demo('Image Overlays', '''
+    Leaflet supports [image overlays](https://leafletjs.com/reference.html#imageoverlay).
+    You can add an image overlay with the `image_overlay` method.
+''')
+def overlay_image():
+    m = ui.leaflet(center=(40.74, -74.18), zoom=11)
+    m.image_overlay(
+        url='https://maps.lib.utexas.edu/maps/historical/newark_nj_1922.jpg',
+        bounds=[[40.712216, -74.22655], [40.773941, -74.12544]],
+        options={'opacity': 0.8},
+    )
+
+
+@doc.demo('Video Overlays', '''
+    Leaflet supports [video overlays](https://leafletjs.com/reference.html#videooverlay).
+    You can add a video overlay with the `video_overlay` method.
+''')
+def overlay_video():
+    m = ui.leaflet(center=(23.0, -115.0), zoom=3)
+    m.video_overlay(
+        url='https://www.mapbox.com/bites/00188/patricia_nasa.webm',
+        bounds=[[32, -130], [13, -100]],
+        options={'opacity': 0.8, 'autoplay': True, 'playsInline': True},
+    )
+
+
 @doc.demo('Vector Layers', '''
     Leaflet supports a set of [vector layers](https://leafletjs.com/reference.html#:~:text=VideoOverlay-,Vector%20Layers,-Path) like circle, polygon etc.
     These can be added with the `generic_layer` method.
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 0e44de93..83e85a60 100644
--- a/website/documentation/content/section_configuration_deployment.py
+++ b/website/documentation/content/section_configuration_deployment.py
@@ -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/documentation/content/user_documentation.py b/website/documentation/content/user_documentation.py
index 22948b47..f442a584 100644
--- a/website/documentation/content/user_documentation.py
+++ b/website/documentation/content/user_documentation.py
@@ -105,6 +105,42 @@ def querying():
             ''')
 
 
+doc.text('Using an ElementFilter', '''
+    It may be desirable to use an [`ElementFilter`](/documentation/element_filter) to
+
+    - preserve the order of elements to check their order on the page, and
+    - more granular filtering options, such as `ElementFilter(...).within(...)`.
+
+    By entering the `user` context and iterating over `ElementFilter`,
+    you can preserve the natural document order of matching elements:
+''')
+
+
+@doc.ui
+def using_an_elementfilter():
+    with ui.row().classes('gap-4 items-stretch'):
+        with python_window(classes='w-[400px]', title='UI code'):
+            ui.markdown('''
+                ```python
+                ui.label('1').mark('number')
+                ui.label('2').mark('number')
+                ui.label('3').mark('number')
+                ```
+            ''')
+
+        with python_window(classes='w-[600px]', title='user assertions'):
+            ui.markdown('''
+                ```python
+                with user:
+                    elements = list(ElementFilter(marker='number'))
+                    assert len(elements) == 3
+                    assert elements[0].text == '1'
+                    assert elements[1].text == '2'
+                    assert elements[2].text == '3'
+                ```
+            ''')
+
+
 doc.text('Complex elements', '''
     There are some elements with complex visualization and interaction behaviors (`ui.upload`, `ui.table`, ...).
     Not every aspect of these elements can be tested with `should_see` and `UserInteraction`.
diff --git a/website/documentation/demo.py b/website/documentation/demo.py
index fb2125f4..54ba1329 100644
--- a/website/documentation/demo.py
+++ b/website/documentation/demo.py
@@ -1,41 +1,16 @@
-import inspect
-import re
 from typing import Callable, Optional, Union
 
-import isort
-
 from nicegui import helpers, json, ui
 
+from .code_extraction import get_full_code
 from .intersection_observer import IntersectionObserver as intersection_observer
 from .windows import browser_window, python_window
 
-UNCOMMENT_PATTERN = re.compile(r'^(\s*)# ?')
-
-
-def _uncomment(text: str) -> str:
-    return UNCOMMENT_PATTERN.sub(r'\1', text)  # NOTE: non-executed lines should be shown in the code examples
-
 
 def demo(f: Callable, *, lazy: bool = True, tab: Optional[Union[str, Callable]] = None) -> Callable:
     """Render a callable as a demo with Python code and browser window."""
     with ui.column().classes('w-full items-stretch gap-8 no-wrap min-[1500px]:flex-row'):
-        code = inspect.getsource(f).split('# END OF DEMO', 1)[0].strip().splitlines()
-        code = [line for line in code if not line.endswith('# HIDE')]
-        while not code[0].strip().startswith(('def', 'async def')):
-            del code[0]
-        del code[0]
-        if code[0].strip().startswith('"""'):
-            while code[0].strip() != '"""':
-                del code[0]
-            del code[0]
-        indentation = len(code[0]) - len(code[0].lstrip())
-        code = [line[indentation:] for line in code]
-        code = ['from nicegui import ui'] + [_uncomment(line) for line in code]
-        code = ['' if line == '#' else line for line in code]
-        if not code[-1].startswith('ui.run('):
-            code.append('')
-            code.append('ui.run()')
-        full_code = isort.code('\n'.join(code), no_sections=True, lines_after_imports=1)
+        full_code = get_full_code(f)
         with python_window(classes='w-full max-w-[44rem]'):
             ui.markdown(f'````python\n{full_code}\n````')
             ui.icon('content_copy', size='xs') \
diff --git a/website/documentation/search.py b/website/documentation/search.py
index c2f25e69..b1057f7d 100644
--- a/website/documentation/search.py
+++ b/website/documentation/search.py
@@ -6,10 +6,13 @@ from fastapi.responses import JSONResponse
 from nicegui import app
 
 from ..examples import examples
+from .code_extraction import get_full_code
 from .content import registry
 
 PATH = Path(__file__).parent.parent / 'static' / 'search_index.json'
 search_index: List[Dict[str, str]] = []
+sitewide_index: List[Dict[str, str]] = []
+examples_index: List[Dict[str, str]] = []
 
 
 @app.get('/static/search_index.json')
@@ -17,20 +20,39 @@ def _get_search_index() -> JSONResponse:
     return JSONResponse(search_index)
 
 
+@app.get('/static/sitewide_index.json')
+def _get_sitewide_index() -> JSONResponse:
+    return JSONResponse(sitewide_index)
+
+
+@app.get('/static/examples_index.json')
+def _get_examples_index() -> JSONResponse:
+    return JSONResponse(examples_index)
+
+
 def build_search_index() -> None:
     """Build search index."""
-    search_index.clear()
-    search_index.extend([
+    search_index[:] = _collect_documentation_parts(include_code=False) + _collect_examples()
+    sitewide_index[:] = _collect_documentation_parts(include_code=True)
+    examples_index[:] = _collect_examples()
+
+
+def _collect_documentation_parts(*, include_code: bool = False) -> List[Dict[str, str]]:
+    return [
         {
             'title': f'{documentation.heading.replace("*", "")}: {part.title}',
             'content': part.description or part.search_text or '',
             'format': part.description_format,
+            **({'demo': get_full_code(part.demo.function) if part.demo is not None else ''} if include_code else {}),
             'url': f'/documentation/{documentation.name}#{part.link_target}',
         }
         for documentation in registry.values()
         for part in documentation.parts
-    ])
-    search_index.extend([
+    ]
+
+
+def _collect_examples() -> List[Dict[str, str]]:
+    return [
         {
             'title': f'Example: {example.title}',
             'content': example.description,
@@ -38,4 +60,4 @@ def build_search_index() -> None:
             'url': example.url,
         }
         for example in examples
-    ])
+    ]


Test patch:
diff --git a/nicegui/testing/user.py b/nicegui/testing/user.py
index e046e042..74575157 100644
--- a/nicegui/testing/user.py
+++ b/nicegui/testing/user.py
@@ -46,6 +46,12 @@ class User:
             raise ValueError('This user has not opened a page yet. Did you forgot to call .open()?')
         return self.client
 
+    def __enter__(self) -> Client:
+        return self._client.__enter__()
+
+    def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
+        return self._client.__exit__(exc_type, exc_val, exc_tb)
+
     def __getattribute__(self, name: str) -> Any:
         if name not in {'notify', 'navigate', 'download'}:  # NOTE: avoid infinite recursion
             ui.navigate = self.navigate
diff --git a/tests/test_codemirror.py b/tests/test_codemirror.py
index 97d7ba8d..8137b699 100644
--- a/tests/test_codemirror.py
+++ b/tests/test_codemirror.py
@@ -1,9 +1,12 @@
 from typing import Dict, List
 
+import pytest
+
 from nicegui import ui
-from nicegui.elements.codemirror import _apply_change_set
 from nicegui.testing import Screen
 
+# pylint: disable=protected-access
+
 
 def test_codemirror(screen: Screen):
     ui.codemirror('Line 1\nLine 2\nLine 3')
@@ -34,11 +37,29 @@ def test_supported_values(screen: Screen):
     assert values['themes'] == values['supported_themes']
 
 
-def test_change_set():
-    assert _apply_change_set('', [0, 1], [['A']]) == 'A'
-    assert _apply_change_set('', [0, 2], [['AB']]) == 'AB'
-    assert _apply_change_set('X', [1, 2], [['AB']]) == 'AB'
-    assert _apply_change_set('X', [1, -1], []) == 'X'
-    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!'
+@pytest.mark.parametrize('doc, sections, inserted, expected', [
+    ('', [0, 1], [['A']], 'A'),
+    ('', [0, 2], [['AB']], 'AB'),
+    ('X', [1, 2], [['AB']], 'AB'),
+    ('X', [1, -1], [], 'X'),
+    ('X', [1, -1, 0, 1], [[], ['Y']], 'XY'),
+    ('Hello', [5, -1, 0, 8], [[], [', world!']], 'Hello, world!'),
+    ('Hello, world!', [5, -1, 7, 0, 1, -1], [], 'Hello!'),
+    ('Hello, hello!', [2, -1, 3, 1, 4, -1, 3, 1, 1, -1], [[], ['y'], [], ['y']], 'Hey, hey!'),
+    ('Hello, world!', [5, -1, 1, 3, 7, -1], [[], [' 🙂']], 'Hello 🙂 world!'),
+    ('Hey! 🙂', [7, -1, 0, 4], [[], [' Ho!']], 'Hey! 🙂 Ho!'),
+    ('Ha 🙂\nha 🙂', [3, -1, 2, 0, 4, -1, 2, 0], [[], [''], [], ['']], 'Ha \nha '),
+])
+def test_change_set(screen: Screen, doc: str, sections: List[int], inserted: List[List[str]], expected: str):
+    editor = ui.codemirror(doc)
+
+    screen.open('/')
+    assert editor._apply_change_set(sections, inserted) == expected
+
+
+def test_encode_codepoints():
+    assert ui.codemirror._encode_codepoints('') == b''
+    assert ui.codemirror._encode_codepoints('Hello') == bytes([1, 1, 1, 1, 1])
+    assert ui.codemirror._encode_codepoints('🙂') == bytes([0, 1])
+    assert ui.codemirror._encode_codepoints('Hello 🙂') == bytes([1, 1, 1, 1, 1, 1, 0, 1])
+    assert ui.codemirror._encode_codepoints('😎😎😎') == bytes([0, 1, 0, 1, 0, 1])
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)
diff --git a/tests/test_user_simulation.py b/tests/test_user_simulation.py
index 14a3f5c9..b1ce3e7f 100644
--- a/tests/test_user_simulation.py
+++ b/tests/test_user_simulation.py
@@ -8,7 +8,7 @@ from fastapi import UploadFile
 from fastapi.datastructures import Headers
 from fastapi.responses import PlainTextResponse
 
-from nicegui import app, events, ui
+from nicegui import ElementFilter, app, events, ui
 from nicegui.testing import User
 
 # pylint: disable=missing-function-docstring
@@ -568,3 +568,12 @@ async def test_run_javascript(user: User):
     user.javascript_rules[re.compile(r'Math.sqrt\((\d+)\)')] = lambda match: int(match.group(1))**0.5
     await user.open('/')
     await user.should_see('42')
+
+
+async def test_context_manager(user: User) -> None:
+    ui.button('click me')
+
+    await user.open('/')
+    with user:
+        elements = list(ElementFilter(kind=ui.button))
+    assert len(elements) == 1 and isinstance(elements[0], ui.button)