Repository: zauberzeug/nicegui
Pull request #4682 (2025-04-30 13:57:05)

Add context manager to the user fixture
This PR fix https://github.com/zauberzeug/nicegui/discussions/4667 by making user a context manager. By implementing `__enter__` and `__exit__` for user, we can with user: and then use the `ElementFilter` directly, overcoming how `user.find` returns a set, and enabling checking the order of filtered elements in testing.

evnchn: @barrywhart Mind if you make the PR self-descriptive by looking at only the first comment? Since it will be the merge message when we eventually merge the PR*

Something like: 

This PR fix #4667 by making user a context manager. By implementing `__enter__` and `__exit__` for `user`, we can `with user:` and then use the ElementFilter directly, overcoming how `user.find` returns a set, and enabling checking the order of filtered elements in testing. 

---

Meanwhile, some issues, from important to less important: 

* Did we not agree to hold back adding helper function `one`? Why is it in the docstring? 
* Perhaps we need to add documentation, as Rodja suggested? 

*the same mistake I make when I contribute the first time 😅
barrywhart: I changed the PR description as you suggested. Is that what you had in mind?
barrywhart: I updated the docstring to remove any mention of `one()` -- good catch!
barrywhart: What do you have in mind for documentation? Perhaps updating this file`website/documentation/content/user_documentation.py`? How can I build and view the documentation after changing it?
evnchn: Would prefer having another `doc.text` and `@doc.ui` section. 

Drafted some text and code for the documentation. See if you think it's fine. 

Text: 
### Using ElementFilter directly
> Since `user.find` uses `set` to avoid duplicates, it does not preserve the order. 
> Consider how the order is not preserved after converting to a set and back in `list(set(["1", "2", "3"])) = ["3", "1", "2"]`. 
> Instead, it is possible to use ElementFilter directly by entering the user context. 

UI code would be: 
```py
ui.label("1").mark("text-123")
ui.label("2").mark("text-123")
ui.label("3").mark("text-123")
```

Testing code would be:
```py
with user:
    expected_text = ["1", "2", "3"]
    assert [label.text == expected for label, expected in zip(ElementFilter(marker="text-123"), expected_text)]
```

Other than that, LGTM. 
barrywhart: @evnchn: I updated the documentation as you suggested.

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/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/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 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/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 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/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
-    ])
+    ]
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/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..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)
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)