Repository: zauberzeug/nicegui
Pull request #4688 (2025-05-02 21:32:54)

Sitewide index for AI
This PR fix #3412 by serving another 2 JSON files in addition to the existing `search_index.json`. 

- /static/sitewide_index.json: Compared to `search_index.json`, it has the code of the documentation pages, but it does not have the examples

- /static/examples_index.json: Just the examples as we would otherwise see in `search_index.json`

This greatly benefits AI as they can learn about the syntax of NiceGUI, and no need to worry about 2 mixed items in the `search_index.json` muddying the waters and making AI difficult to learn from the pattern. 

---

There should not be any impact on the existing NiceGUI website. 

evnchn: Next move would be, hopefully, to implement some sort of LLM agent. This PR prepares for me and others to do that. 
evnchn: The following is the upper bound of what the AI agent can achieve. Using DeepSeek R1 with putting the entire JSON in the context window via attachment. 

https://poe.com/s/vqlCa2Se7VTQTGDW6yrm

```py
from nicegui import ui

def caesar_cipher(text: str, shift: int = 3) -> str:
    result = []
    for char in text:
        if char.isalpha():
            start = ord('A') if char.isupper() else ord('a')
            new_char = chr((ord(char) - start + shift) % 26 + start)
            result.append(new_char)
        else:
            result.append(char)
    return ''.join(result)

@ui.page('/text_transformer')
def text_transformer():
    with ui.column().classes('w-full items-stretch gap-4 p-4'):
        input_text = ui.textarea(label='Input Text').classes('w-full')
        output = ui.label().classes('text-lg font-mono')
        
        with ui.row().classes('gap-2'):
            ui.button('UPPERCASE', on_click=lambda: output.set_text(input_text.value.upper()))
            ui.button('lowercase', on_click=lambda: output.set_text(input_text.value.lower()))
            ui.button('Caesar Cipher', on_click=lambda: output.set_text(caesar_cipher(input_text.value)))
            ui.button('Reverse', on_click=lambda: output.set_text(input_text.value[::-1]))
            ui.button('Clear', on_click=lambda: (input_text.set_value(''), output.set_text('')))
        
        ui.separator()
        with ui.expansion('Advanced Options', icon='settings').classes('w-full'):
            with ui.row():
                shift = ui.number('Caesar Shift', value=3, min=1, max=25)
                ui.button('Apply Shift', on_click=lambda: output.set_text(
                    caesar_cipher(input_text.value, int(shift.value))
                ))

ui.run()
```

<img width="639" alt="{079F2F60-3FD8-4C8E-831A-496C8EEC9765}" src="https://github.com/user-attachments/assets/1bffcef9-0f07-4bd3-b251-f4aadfc2429f" />


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