Explorar el Código

Merge branch 'main' into markbaumgarten/main

Falko Schindler hace 2 años
padre
commit
bc3e015c31

+ 1 - 1
fly.toml

@@ -12,7 +12,7 @@ processes = []
 [deploy]
 # boot a single, new VM with the new release, verify its health, then
 # One by one, each running VM is taken down and replaced by the new release VM
-strategy = "canary" 
+strategy = "rolling" 
 
 
 [experimental]

+ 50 - 38
main.py

@@ -10,6 +10,7 @@ if True:
 
 import os
 from pathlib import Path
+from typing import Optional
 
 from fastapi import Request
 from fastapi.responses import FileResponse, RedirectResponse
@@ -61,7 +62,7 @@ def add_head_html() -> None:
     ui.add_head_html(f"<style>{(Path(__file__).parent / 'website' / 'static' / 'style.css').read_text()}</style>")
 
 
-def add_header() -> None:
+def add_header(menu: Optional[ui.left_drawer] = None) -> None:
     menu_items = {
         'Installation': '/#installation',
         'Features': '/#features',
@@ -73,14 +74,12 @@ def add_header() -> None:
     with ui.header() \
             .classes('items-center duration-200 p-0 px-4 no-wrap') \
             .style('box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1)'):
+        if menu:
+            ui.button(on_click=menu.toggle).props('flat color=white icon=menu round') \
+                .classes('max-[405px]:hidden lg:hidden')
         with ui.link(target=index_page).classes('row gap-4 items-center no-wrap mr-auto'):
             svg.face().classes('w-8 stroke-white stroke-2')
             svg.word().classes('w-24')
-        with ui.row().classes('lg:hidden'):
-            with ui.menu().classes('bg-primary text-white text-lg') as menu:
-                for title, target in menu_items.items():
-                    ui.menu_item(title, on_click=lambda _, target=target: ui.open(target))
-            ui.button(on_click=menu.open).props('flat color=white icon=menu')
         with ui.row().classes('max-lg:hidden'):
             for title, target in menu_items.items():
                 ui.link(title, target).classes(replace='text-lg text-white')
@@ -88,7 +87,12 @@ def add_header() -> None:
             svg.discord().classes('fill-white scale-125 m-1')
         with ui.link(target='https://github.com/zauberzeug/nicegui/'):
             svg.github().classes('fill-white scale-125 m-1')
-        add_star()
+        add_star().classes('max-[460px]:hidden')
+        with ui.row().classes('lg:hidden'):
+            with ui.button().props('flat color=white icon=more_vert round'):
+                with ui.menu().classes('bg-primary text-white text-lg').props(remove='no-parent-event'):
+                    for title, target in menu_items.items():
+                        ui.menu_item(title, on_click=lambda _, target=target: ui.open(target))
 
 
 @ui.page('/')
@@ -136,18 +140,25 @@ async def index_page(client: Client):
                 ui.html('<em>1.</em>').classes('text-3xl font-bold')
                 ui.markdown('Create __main.py__').classes('text-lg')
                 with python_window(classes='w-full h-52'):
-                    ui.markdown('''```python\n
-from nicegui import ui
+                    ui.markdown('''
+                        ```python\n
+                        from nicegui import ui
 
-ui.label('Hello NiceGUI!')
+                        ui.label('Hello NiceGUI!')
 
-ui.run()
-```''')
+                        ui.run()
+                        ```
+                    ''')
             with ui.column().classes('w-full max-w-md gap-2'):
                 ui.html('<em>2.</em>').classes('text-3xl font-bold')
                 ui.markdown('Install and launch').classes('text-lg')
                 with bash_window(classes='w-full h-52'):
-                    ui.markdown('```bash\npip3 install nicegui\npython3 main.py\n```')
+                    ui.markdown('''
+                        ```bash
+                        pip3 install nicegui
+                        python3 main.py
+                        ```
+                    ''')
             with ui.column().classes('w-full max-w-md gap-2'):
                 ui.html('<em>3.</em>').classes('text-3xl font-bold')
                 ui.markdown('Enjoy!').classes('text-lg')
@@ -156,15 +167,18 @@ ui.run()
         with ui.expansion('...or use Docker to run your main.py').classes('w-full gap-2 bold-links arrow-links'):
             with ui.row().classes('mt-8 w-full justify-center items-center gap-8'):
                 ui.markdown('''
-With our [multi-arch Docker image](https://hub.docker.com/repository/docker/zauberzeug/nicegui) 
-you can start the server without installing any packages.
+                    With our [multi-arch Docker image](https://hub.docker.com/repository/docker/zauberzeug/nicegui) 
+                    you can start the server without installing any packages.
 
-The command searches for `main.py` in in your current directory and makes the app available at http://localhost:8888.
-''').classes('max-w-xl')
+                    The command searches for `main.py` in in your current directory and makes the app available at http://localhost:8888.
+                ''').classes('max-w-xl')
                 with bash_window(classes='max-w-lg w-full h-52'):
-                    ui.markdown('```bash\n'
-                                'docker run -it --rm -p 8888:8080 \\\n -v "$PWD":/app zauberzeug/nicegui\n'
-                                '```')
+                    ui.markdown('''
+                        ```bash
+                        docker run -it --rm -p 8888:8080 \\
+                            -v "$PWD":/app zauberzeug/nicegui
+                        ```
+                    ''')
 
     with ui.column().classes('w-full p-8 lg:p-16 bold-links arrow-links max-w-[1600px] mx-auto'):
         link_target('features', '-50px')
@@ -229,18 +243,16 @@ The command searches for `main.py` in in your current directory and makes the ap
         with ui.row().classes('w-full text-lg leading-tight grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4'):
             example_link('Slideshow', 'implements a keyboard-controlled image slideshow')
             example_link('Authentication', 'shows how to use sessions to build a login screen')
-            example_link(
-                'Modularization',
-                'provides an example of how to modularize your application into multiple files and reuse code')
-            example_link(
-                'FastAPI',
-                'illustrates the integration of NiceGUI with an existing FastAPI application')
-            example_link(
-                'Map',
-                'demonstrates wrapping the JavaScript library [leaflet](https://leafletjs.com/) to display a map at specific locations')
-            example_link(
-                'AI Interface',
-                'utilizes the [replicate](https://replicate.com) library to perform voice-to-text transcription and generate images from prompts with Stable Diffusion')
+            example_link('Modularization',
+                         'provides an example of how to modularize your application into multiple files and reuse code')
+            example_link('FastAPI',
+                         'illustrates the integration of NiceGUI with an existing FastAPI application')
+            example_link('Map',
+                         'demonstrates wrapping the JavaScript library [leaflet](https://leafletjs.com/) '
+                         'to display a map at specific locations')
+            example_link('AI Interface',
+                         'utilizes the [replicate](https://replicate.com) library to perform voice-to-text '
+                         'transcription and generate images from prompts with Stable Diffusion')
             example_link('3D Scene', 'creates a webGL view and loads an STL mesh illuminated with a spotlight')
             example_link('Custom Vue Component', 'shows how to write and integrate a custom Vue component')
             example_link('Image Mask Overlay', 'shows how to overlay an image with a mask')
@@ -300,15 +312,15 @@ The command searches for `main.py` in in your current directory and makes the ap
 @ui.page('/documentation')
 def documentation_page():
     add_head_html()
-    add_header()
-    side_menu()
+    menu = side_menu()
+    add_header(menu)
     ui.add_head_html('<style>html {scroll-behavior: auto;}</style>')
     with ui.column().classes('w-full p-8 lg:p-16 max-w-[1250px] mx-auto'):
         section_heading('Reference, Demos and more', '*NiceGUI* Documentation')
-        ui.markdown(
-            'This is the documentation for NiceGUI >= 1.0. '
-            'Documentation for older versions can be found at [https://0.9.nicegui.io/](https://0.9.nicegui.io/reference).'
-        ).classes('bold-links arrow-links')
+        ui.markdown('''
+            This is the documentation for NiceGUI >= 1.0.
+            Documentation for older versions can be found at [https://0.9.nicegui.io/](https://0.9.nicegui.io/reference).
+        ''').classes('bold-links arrow-links')
         documentation.create_full()
 
 

+ 21 - 7
nicegui/binding.py

@@ -2,7 +2,7 @@ import asyncio
 import logging
 import time
 from collections import defaultdict
-from typing import Any, Callable, DefaultDict, Dict, List, Optional, Set, Tuple, Type
+from typing import Any, Callable, DefaultDict, Dict, List, Optional, Set, Tuple, Type, Union
 
 from . import globals
 
@@ -11,15 +11,29 @@ bindable_properties: Dict[Tuple[int, str], Any] = {}
 active_links: List[Tuple[Any, str, Any, str, Callable]] = []
 
 
+def get_attribute(obj: Union[object, Dict], name: str) -> Any:
+    if isinstance(obj, dict):
+        return obj[name]
+    else:
+        return getattr(obj, name)
+
+
+def set_attribute(obj: Union[object, Dict], name: str, value: Any) -> None:
+    if isinstance(obj, dict):
+        obj[name] = value
+    else:
+        setattr(obj, name, value)
+
+
 async def loop():
     while True:
         visited: Set[Tuple[int, str]] = set()
         t = time.time()
         for link in active_links:
             (source_obj, source_name, target_obj, target_name, transform) = link
-            value = transform(getattr(source_obj, source_name))
-            if getattr(target_obj, target_name) != value:
-                setattr(target_obj, target_name, value)
+            value = transform(get_attribute(source_obj, source_name))
+            if get_attribute(target_obj, target_name) != value:
+                set_attribute(target_obj, target_name, value)
                 propagate(target_obj, target_name, visited)
             del link, source_obj, target_obj
         if time.time() - t > 0.01:
@@ -34,9 +48,9 @@ def propagate(source_obj: Any, source_name: str, visited: Optional[Set[Tuple[int
     for _, target_obj, target_name, transform in bindings.get((id(source_obj), source_name), []):
         if (id(target_obj), target_name) in visited:
             continue
-        target_value = transform(getattr(source_obj, source_name))
-        if getattr(target_obj, target_name) != target_value:
-            setattr(target_obj, target_name, target_value)
+        target_value = transform(get_attribute(source_obj, source_name))
+        if get_attribute(target_obj, target_name) != target_value:
+            set_attribute(target_obj, target_name, target_value)
             propagate(target_obj, target_name, visited)
 
 

+ 1 - 0
nicegui/element.py

@@ -227,6 +227,7 @@ class Element(Visibility):
                 trailing_events=trailing_events,
             )
             self._event_listeners[listener.id] = listener
+            self.update()
         return self
 
     def _handle_event(self, msg: Dict) -> None:

+ 24 - 0
nicegui/elements/grid.py

@@ -0,0 +1,24 @@
+from typing import Optional
+
+from ..element import Element
+
+
+class Grid(Element):
+
+    def __init__(self,
+                 rows: Optional[int] = None,
+                 columns: Optional[int] = None,
+                 ) -> None:
+        '''Grid Element
+
+        Provides a container which arranges its child in a grid.
+
+        :param rows: number of rows in the grid
+        :param columns: number of columns in the grid
+        '''
+        super().__init__('div')
+        self._classes = ['nicegui-grid']
+        if rows is not None:
+            self._style['grid-template-rows'] = f'repeat({rows}, minmax(0, 1fr))'
+        if columns is not None:
+            self._style['grid-template-columns'] = f'repeat({columns}, minmax(0, 1fr))'

+ 23 - 1
nicegui/elements/input.py

@@ -1,4 +1,4 @@
-from typing import Any, Callable, Dict, Optional
+from typing import Any, Callable, Dict, List, Optional
 
 from .icon import Icon
 from .mixins.disableable_element import DisableableElement
@@ -15,6 +15,7 @@ class Input(ValueElement, DisableableElement):
                  password: bool = False,
                  password_toggle_button: bool = False,
                  on_change: Optional[Callable] = None,
+                 autocomplete: Optional[List[str]] = None,
                  validation: Dict[str, Callable] = {}) -> None:
         """Text Input
 
@@ -33,6 +34,7 @@ class Input(ValueElement, DisableableElement):
         :param password: whether to hide the input (default: False)
         :param password_toggle_button: whether to show a button to toggle the password visibility (default: False)
         :param on_change: callback to execute when the value changes
+        :param autocomplete: optional list of strings for autocompletion
         :param validation: dictionary of validation rules, e.g. ``{'Too short!': lambda value: len(value) < 3}``
         """
         super().__init__(tag='q-input', value=value, on_value_change=on_change)
@@ -52,6 +54,26 @@ class Input(ValueElement, DisableableElement):
 
         self.validation = validation
 
+        if autocomplete:
+            def find_autocompletion() -> Optional[str]:
+                if self.value:
+                    for item in autocomplete:
+                        if item.startswith(self.value):
+                            return item
+
+            def autocomplete_input() -> None:
+                match = find_autocompletion() or ''
+                self.props(f'shadow-text="{match[len(self.value):]}"')
+
+            def complete_input() -> None:
+                match = find_autocompletion()
+                if match:
+                    self.set_value(match)
+                self.props(f'shadow-text=""')
+
+            self.on('keyup', autocomplete_input)
+            self.on('keydown.tab', complete_input)
+
     def on_value_change(self, value: Any) -> None:
         super().on_value_change(value)
         for message, check in self.validation.items():

+ 1 - 0
nicegui/elements/markdown.py

@@ -24,6 +24,7 @@ class Markdown(ContentElement):
         """
         self.extras = extras
         super().__init__(tag='markdown', content=content)
+        self._classes = ['nicegui-markdown']
         self._props['codehilite_css'] = HtmlFormatter(nobackground=True).get_style_defs('.codehilite')
 
     def on_content_change(self, content: str) -> None:

+ 52 - 1
nicegui/elements/mixins/disableable_element.py

@@ -1,4 +1,8 @@
-from ...binding import BindableProperty
+from typing import Any, Callable
+
+from typing_extensions import Self
+
+from ...binding import BindableProperty, bind, bind_from, bind_to
 from ...element import Element
 
 
@@ -17,6 +21,53 @@ class DisableableElement(Element):
         """Disable the element."""
         self.enabled = False
 
+    def bind_enabled_to(self,
+                        target_object: Any,
+                        target_name: str = 'enabled',
+                        forward: Callable = lambda x: x) -> Self:
+        """Bind the enabled state of this element to the target object's target_name property.
+
+        The binding works one way only, from this element to the target.
+
+        :param target_object: The object to bind to.
+        :param target_name: The name of the property to bind to.
+        :param forward: A function to apply to the value before applying it to the target.
+        """
+        bind_to(self, 'enabled', target_object, target_name, forward)
+        return self
+
+    def bind_enabled_from(self,
+                          target_object: Any,
+                          target_name: str = 'enabled',
+                          backward: Callable = lambda x: x) -> Self:
+        """Bind the enabled state of this element from the target object's target_name property.
+
+        The binding works one way only, from the target to this element.
+
+        :param target_object: The object to bind from.
+        :param target_name: The name of the property to bind from.
+        :param backward: A function to apply to the value before applying it to this element.
+        """
+        bind_from(self, 'enabled', target_object, target_name, backward)
+        return self
+
+    def bind_enabled(self,
+                     target_object: Any,
+                     target_name: str = 'enabled', *,
+                     forward: Callable = lambda x: x,
+                     backward: Callable = lambda x: x) -> Self:
+        """Bind the enabled state of this element to the target object's target_name property.
+
+        The binding works both ways, from this element to the target and from the target to this element.
+
+        :param target_object: The object to bind to.
+        :param target_name: The name of the property to bind to.
+        :param forward: A function to apply to the value before applying it to the target.
+        :param backward: A function to apply to the value before applying it to this element.
+        """
+        bind(self, 'enabled', target_object, target_name, forward=forward, backward=backward)
+        return self
+
     def set_enabled(self, value: bool) -> None:
         """Set the enabled state of the element."""
         self.enabled = value

+ 1 - 1
nicegui/elements/number.py

@@ -62,7 +62,7 @@ class Number(ValueElement, DisableableElement):
         value = float(self.value or 0)
         value = max(value, self._props.get('min', -float('inf')))
         value = min(value, self._props.get('max', float('inf')))
-        self.set_value(self.format % value if self.format else str(value))
+        self.set_value(float(self.format % value) if self.format else value)
 
     def on_value_change(self, value: Any) -> None:
         super().on_value_change(value)

+ 5 - 0
nicegui/elements/video.js

@@ -1,5 +1,10 @@
 export default {
   template: `<video :controls="controls" :autoplay="autoplay" :muted="muted" :src="src" />`,
+  methods: {
+    seek(seconds) {
+      this.$el.currentTime = seconds;
+    },
+  },
   props: {
     controls: Boolean,
     autoplay: Boolean,

+ 7 - 0
nicegui/elements/video.py

@@ -36,3 +36,10 @@ class Video(Element):
         if type:
             url = f'https://github.com/zauberzeug/nicegui/pull/624'
             warnings.warn(DeprecationWarning(f'The type parameter for ui.video is deprecated and ineffective ({url}).'))
+
+    def seek(self, seconds: float) -> None:
+        """Seek to a specific position in the video.
+
+        :param seconds: the position in seconds
+        """
+        self.run_method('seek', seconds)

+ 19 - 4
nicegui/favicon.py

@@ -1,4 +1,4 @@
-import urllib
+import urllib.parse
 from pathlib import Path
 from typing import TYPE_CHECKING, Optional
 
@@ -25,7 +25,11 @@ def get_favicon_url(page: 'page', prefix: str) -> str:
         return favicon
     elif not favicon:
         return f'{prefix}/_nicegui/{__version__}/static/favicon.ico'
-    if is_char(favicon):
+    elif is_data_url(favicon):
+        return favicon
+    elif is_svg(favicon):
+        return svg_to_data_url(favicon)
+    elif is_char(favicon):
         return char_to_data_url(favicon)
     elif page.path == '/':
         return f'{prefix}/favicon.ico'
@@ -41,6 +45,14 @@ def is_char(favicon: str) -> bool:
     return len(favicon) == 1
 
 
+def is_svg(favicon: str) -> bool:
+    return favicon.strip().startswith('<svg')
+
+
+def is_data_url(favicon: str) -> bool:
+    return favicon.startswith('data:')
+
+
 def char_to_data_url(char: str) -> str:
     svg = f'''
         <svg viewBox="0 0 128 128" width="128" height="128" xmlns="http://www.w3.org/2000/svg" >
@@ -58,6 +70,9 @@ def char_to_data_url(char: str) -> str:
             <text y=".9em" font-size="128" font-family="Georgia, sans-serif">{char}</text>
         </svg>
     '''
+    return svg_to_data_url(svg)
+
+
+def svg_to_data_url(svg: str) -> str:
     svg_urlencoded = urllib.parse.quote(svg)
-    data_url = f"data:image/svg+xml,{svg_urlencoded}"
-    return data_url
+    return f'data:image/svg+xml,{svg_urlencoded}'

+ 3 - 0
nicegui/functions/refreshable.js

@@ -0,0 +1,3 @@
+export default {
+  template: `<slot></slot>`,
+};

+ 5 - 2
nicegui/functions/refreshable.py

@@ -2,15 +2,18 @@ from typing import Callable, List
 
 from typing_extensions import Self
 
+from ..dependencies import register_component
 from ..element import Element
 
+register_component('refreshable', __file__, 'refreshable.js')
+
 
 class refreshable:
 
     def __init__(self, func: Callable) -> None:
         """Refreshable UI functions
 
-        The `@refreshable` decorator allows you to create functions that have a `refresh` method.
+        The `@ui.refreshable` decorator allows you to create functions that have a `refresh` method.
         This method will automatically delete all elements created by the function and recreate them.
         """
         self.func = func
@@ -22,7 +25,7 @@ class refreshable:
         return self
 
     def __call__(self) -> None:
-        with Element('div') as container:
+        with Element('refreshable') as container:
             self.func() if self.instance is None else self.func(self.instance)
         self.containers.append(container)
 

+ 14 - 6
nicegui/native_mode.py

@@ -1,6 +1,7 @@
 import _thread
 import multiprocessing
 import socket
+import sys
 import tempfile
 import time
 import warnings
@@ -8,10 +9,13 @@ from threading import Thread
 
 from . import globals, helpers
 
-with warnings.catch_warnings():
-    # webview depends on bottle which uses the deprecated CGI function (https://github.com/bottlepy/bottle/issues/1403)
-    warnings.filterwarnings('ignore', category=DeprecationWarning)
-    import webview
+try:
+    with warnings.catch_warnings():
+        # webview depends on bottle which uses the deprecated CGI function (https://github.com/bottlepy/bottle/issues/1403)
+        warnings.filterwarnings('ignore', category=DeprecationWarning)
+        import webview
+except ModuleNotFoundError:
+    pass
 
 
 def open_window(host: str, port: int, title: str, width: int, height: int, fullscreen: bool) -> None:
@@ -21,8 +25,12 @@ def open_window(host: str, port: int, title: str, width: int, height: int, fulls
     window_kwargs = dict(url=f'http://{host}:{port}', title=title, width=width, height=height, fullscreen=fullscreen)
     window_kwargs.update(globals.app.native.window_args)
 
-    webview.create_window(**window_kwargs)
-    webview.start(storage_path=tempfile.mkdtemp(), **globals.app.native.start_args)
+    try:
+        webview.create_window(**window_kwargs)
+        webview.start(storage_path=tempfile.mkdtemp(), **globals.app.native.start_args)
+    except NameError:
+        print('Native mode is not supported in this configuration. Please install pywebview to use it.')
+        sys.exit(1)
 
 
 def activate(host: str, port: int, title: str, width: int, height: int, fullscreen: bool) -> None:

+ 2 - 1
nicegui/run_with.py

@@ -14,6 +14,7 @@ def run_with(
     dark: Optional[bool] = False,
     binding_refresh_interval: float = 0.1,
     exclude: str = '',
+    mount_path: str = '/',
 ) -> None:
     globals.ui_run_has_been_called = True
     globals.title = title
@@ -27,4 +28,4 @@ def run_with(
     app.on_event('startup')(lambda: handle_startup(with_welcome_message=False))
     app.on_event('shutdown')(lambda: handle_shutdown())
 
-    app.mount('/', globals.app)
+    app.mount(mount_path, globals.app)

+ 9 - 0
nicegui/static/nicegui.css

@@ -36,6 +36,10 @@
   align-items: flex-start;
   gap: 1rem;
 }
+.nicegui-grid {
+  display: grid;
+  gap: 1rem;
+}
 .nicegui-card {
   display: flex;
   flex-direction: column;
@@ -67,6 +71,11 @@
   opacity: 1 !important;
   cursor: text !important;
 }
+.nicegui-markdown blockquote {
+  border-left: 0.25rem solid #8884;
+  padding: 1rem 1rem 0.5rem 1rem;
+  margin: 1rem 0;
+}
 
 #popup {
   position: fixed;

+ 1 - 0
nicegui/ui.py

@@ -21,6 +21,7 @@ from .elements.dark_mode import DarkMode as dark_mode
 from .elements.date import Date as date
 from .elements.dialog import Dialog as dialog
 from .elements.expansion import Expansion as expansion
+from .elements.grid import Grid as grid
 from .elements.html import Html as html
 from .elements.icon import Icon as icon
 from .elements.image import Image as image

+ 27 - 0
tests/test_input.py

@@ -1,4 +1,5 @@
 from selenium.webdriver.common.by import By
+from selenium.webdriver.common.keys import Keys
 
 from nicegui import ui
 
@@ -77,3 +78,29 @@ def test_input_with_multi_word_error_message(screen: Screen):
 
     screen.click('set error')
     screen.should_contain('Some multi word error message')
+
+
+def test_autocompletion(screen: Screen):
+    ui.input('Input', autocomplete=['foo', 'bar', 'baz'])
+
+    screen.open('/')
+    element = screen.selenium.find_element(By.XPATH, '//*[@aria-label="Input"]')
+    element.send_keys('f')
+    screen.should_contain('oo')
+
+    element.send_keys('l')
+    screen.wait(0.5)
+    screen.should_not_contain('oo')
+
+    element.send_keys(Keys.BACKSPACE)
+    screen.should_contain('oo')
+
+    element.send_keys(Keys.TAB)
+    assert element.get_attribute('value') == 'foo'
+
+    element.send_keys(Keys.BACKSPACE)
+    element.send_keys(Keys.BACKSPACE)
+    element.send_keys('x')
+    element.send_keys(Keys.TAB)
+    screen.wait(0.5)
+    assert element.get_attribute('value') == 'fx'

+ 35 - 35
website/demo.py

@@ -1,5 +1,5 @@
 import inspect
-from typing import Callable, Optional
+from typing import Callable, Optional, Union
 
 import isort
 
@@ -19,37 +19,32 @@ def remove_prefix(text: str, prefix: str) -> str:
     return text[len(prefix):] if text.startswith(prefix) else text
 
 
-class demo:
-
-    def __init__(self, browser_title: Optional[str] = None) -> None:
-        self.browser_title = browser_title
-
-    def __call__(self, f: Callable) -> Callable:
-        with ui.column().classes('w-full items-stretch gap-8 no-wrap min-[1500px]:flex-row'):
-            code = inspect.getsource(f).split('# END OF DEMO')[0].strip().splitlines()
-            while not code[0].strip().startswith('def') and not code[0].strip().startswith('async def'):
-                del code[0]
+def demo(f: Callable) -> Callable:
+    with ui.column().classes('w-full items-stretch gap-8 no-wrap min-[1500px]:flex-row'):
+        code = inspect.getsource(f).split('# END OF DEMO')[0].strip().splitlines()
+        while not code[0].strip().startswith('def') and not code[0].strip().startswith('async def'):
             del code[0]
-            indentation = len(code[0]) - len(code[0].lstrip())
-            code = [line[indentation:] for line in code]
-            code = ['from nicegui import ui'] + [remove_prefix(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()')
-            code = isort.code('\n'.join(code), no_sections=True, lines_after_imports=1)
-            with python_window(classes='w-full max-w-[44rem]'):
-                async def copy_code():
-                    await ui.run_javascript('navigator.clipboard.writeText(`' + code + '`)', respond=False)
-                    ui.notify('Copied to clipboard', type='positive', color='primary')
-                ui.markdown(f'````python\n{code}\n````')
-                ui.icon('content_copy', size='xs') \
-                    .classes('absolute right-2 top-10 opacity-10 hover:opacity-80 cursor-pointer') \
-                    .on('click', copy_code)
-            with browser_window(self.browser_title,
-                                classes='w-full max-w-[44rem] min-[1500px]:max-w-[20rem] min-h-[10rem] browser-window'):
-                intersection_observer(on_intersection=f)
-        return f
+        del code[0]
+        indentation = len(code[0]) - len(code[0].lstrip())
+        code = [line[indentation:] for line in code]
+        code = ['from nicegui import ui'] + [remove_prefix(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()')
+        code = isort.code('\n'.join(code), no_sections=True, lines_after_imports=1)
+        with python_window(classes='w-full max-w-[44rem]'):
+            async def copy_code():
+                await ui.run_javascript('navigator.clipboard.writeText(`' + code + '`)', respond=False)
+                ui.notify('Copied to clipboard', type='positive', color='primary')
+            ui.markdown(f'````python\n{code}\n````')
+            ui.icon('content_copy', size='xs') \
+                .classes('absolute right-2 top-10 opacity-10 hover:opacity-80 cursor-pointer') \
+                .on('click', copy_code)
+        with browser_window(title=getattr(f, 'tab', None),
+                            classes='w-full max-w-[44rem] min-[1500px]:max-w-[20rem] min-h-[10rem] browser-window'):
+            intersection_observer(on_intersection=f)
+    return f
 
 
 def _window_header(bgcolor: str) -> ui.row():
@@ -67,16 +62,21 @@ def _title(title: str) -> None:
     ui.label(title).classes('text-sm text-gray-600 absolute left-1/2 top-[6px]').style('transform: translateX(-50%)')
 
 
-def _tab(name: str, color: str, bgcolor: str) -> None:
+def _tab(content: Union[str, Callable], color: str, bgcolor: str) -> None:
     with ui.row().classes('gap-0'):
         with ui.label().classes(f'w-2 h-[24px] bg-[{color}]'):
             ui.label().classes(f'w-full h-full bg-[{bgcolor}] rounded-br-[6px]')
-        ui.label(name).classes(f'text-sm text-gray-600 px-6 py-1 h-[24px] rounded-t-[6px] bg-[{color}]')
+        with ui.row().classes(f'text-sm text-gray-600 px-6 py-1 h-[24px] rounded-t-[6px] bg-[{color}] items-center gap-2'):
+            if callable(content):
+                content()
+            else:
+                ui.label(content)
         with ui.label().classes(f'w-2 h-[24px] bg-[{color}]'):
             ui.label().classes(f'w-full h-full bg-[{bgcolor}] rounded-bl-[6px]')
 
 
-def window(color: str, bgcolor: str, *, title: str = '', tab: str = '', classes: str = '') -> ui.column:
+def window(color: str, bgcolor: str, *,
+           title: str = '', tab: Union[str, Callable] = '', classes: str = '') -> ui.column:
     with ui.card().classes(f'no-wrap bg-[{color}] rounded-xl p-0 gap-0 {classes}') \
             .style('box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1)'):
         with _window_header(bgcolor):
@@ -96,5 +96,5 @@ def bash_window(*, classes: str = '') -> ui.card:
     return window(BASH_COLOR, BASH_BGCOLOR, title='bash', classes=classes).classes('p-2 bash-window')
 
 
-def browser_window(title: Optional[str] = None, *, classes: str = '') -> ui.card:
+def browser_window(title: Optional[Union[str, Callable]] = None, *, classes: str = '') -> ui.card:
     return window(BROWSER_COLOR, BROWSER_BGCOLOR, tab=title or 'NiceGUI', classes=classes).classes('p-4 browser-window')

+ 63 - 52
website/documentation.py

@@ -144,6 +144,7 @@ def create_full() -> None:
     load_demo(ui.card)
     load_demo(ui.column)
     load_demo(ui.row)
+    load_demo(ui.grid)
 
     @text_demo('Clear Containers', '''
         To remove all elements from a row, column or card container, use the `clear()` method.
@@ -241,52 +242,66 @@ def create_full() -> None:
         [Quasar props](https://justpy.io/quasar_tutorial/introduction/#props-of-quasar-components),
         and CSS styles affect NiceGUI elements.
     ''').classes('bold-links arrow-links')
-    with ui.column().classes('w-full items-stretch gap-8 no-wrap min-[1500px]:flex-row'):
-        with demo.python_window(classes='w-full max-w-[44rem]'):
-            with ui.column().classes('w-full gap-4'):
-                ui.markdown('''
-                ```py
-                from nicegui import ui
-
-                button = ui.button('Button')
-                ```
-                ''').classes('mb-[-0.25em]')
-                with ui.row().classes('items-center gap-0 w-full px-2'):
-                    def handle_classes(e: events.ValueChangeEventArguments):
-                        try:
-                            b.classes(replace=e.value)
-                        except ValueError:
-                            pass
-                    ui.markdown("`button.classes('`")
-                    ui.input(on_change=handle_classes).classes('mt-[-0.5em] text-mono grow').props('dense')
-                    ui.markdown("`')`")
-                with ui.row().classes('items-center gap-0 w-full px-2'):
-                    def handle_props(e: events.ValueChangeEventArguments):
-                        b._props = {'label': 'Button', 'color': 'primary'}
-                        try:
-                            b.props(e.value)
-                        except ValueError:
-                            pass
-                        b.update()
-                    ui.markdown("`button.props('`")
-                    ui.input(on_change=handle_props).classes('mt-[-0.5em] text-mono grow').props('dense')
-                    ui.markdown("`')`")
-                with ui.row().classes('items-center gap-0 w-full px-2'):
-                    def handle_style(e: events.ValueChangeEventArguments):
-                        try:
-                            b.style(replace=e.value)
-                        except ValueError:
-                            pass
-                    ui.markdown("`button.style('`")
-                    ui.input(on_change=handle_style).classes('mt-[-0.5em] text-mono grow').props('dense')
-                    ui.markdown("`')`")
-                ui.markdown('''
-                ```py
-                ui.run()
-                ```
-                ''')
-        with demo.browser_window(classes='w-full max-w-[44rem] min-[1500px]:max-w-[20rem] min-h-[10rem] browser-window'):
-            b = ui.button('Button')
+    with ui.row():
+        ui.label('Choose your favorite element from those available and start having fun!').classes('mx-auto my-auto')
+        select_element = ui.select({
+            ui.label: 'ui.label',
+            ui.checkbox: 'ui.checkbox',
+            ui.switch: 'ui.switch',
+            ui.input: 'ui.input',
+            ui.textarea: 'ui.textarea',
+            ui.button: 'ui.button',
+        }, value=ui.button, on_change=lambda: live_demo_ui.refresh()).props('dense')
+
+    @ui.refreshable
+    def live_demo_ui():
+        with ui.column().classes('w-full items-stretch gap-8 no-wrap min-[1500px]:flex-row'):
+            with demo.python_window(classes='w-full max-w-[44rem]'):
+                with ui.column().classes('w-full gap-4'):
+                    ui.markdown(f'''
+                        ```py
+                        from nicegui import ui
+
+                        element = {select_element.options[select_element.value]}('element')
+                        ```
+                    ''').classes('mb-[-0.25em]')
+                    with ui.row().classes('items-center gap-0 w-full px-2'):
+                        def handle_classes(e: events.ValueChangeEventArguments):
+                            try:
+                                element.classes(replace=e.value)
+                            except ValueError:
+                                pass
+                        ui.markdown("`element.classes('`")
+                        ui.input(on_change=handle_classes).classes('mt-[-0.5em] text-mono grow').props('dense')
+                        ui.markdown("`')`")
+                    with ui.row().classes('items-center gap-0 w-full px-2'):
+                        def handle_props(e: events.ValueChangeEventArguments):
+                            element._props = {'label': 'Button', 'color': 'primary'}
+                            try:
+                                element.props(e.value)
+                            except ValueError:
+                                pass
+                            element.update()
+                        ui.markdown("`element.props('`")
+                        ui.input(on_change=handle_props).classes('mt-[-0.5em] text-mono grow').props('dense')
+                        ui.markdown("`')`")
+                    with ui.row().classes('items-center gap-0 w-full px-2'):
+                        def handle_style(e: events.ValueChangeEventArguments):
+                            try:
+                                element.style(replace=e.value)
+                            except ValueError:
+                                pass
+                        ui.markdown("`element.style('`")
+                        ui.input(on_change=handle_style).classes('mt-[-0.5em] text-mono grow').props('dense')
+                        ui.markdown("`')`")
+                    ui.markdown('''
+                        ```py
+                        ui.run()
+                        ```
+                    ''')
+            with demo.browser_window(classes='w-full max-w-[44rem] min-[1500px]:max-w-[20rem] min-h-[10rem] browser-window'):
+                element: ui.element = select_element.value("element")
+    live_demo_ui()
 
     @text_demo('Tailwind CSS', '''
         [Tailwind CSS](https://tailwindcss.com/) is a CSS framework for rapidly building custom user interfaces.
@@ -632,11 +647,7 @@ def create_full() -> None:
 
     heading('Configuration')
 
-    @element_demo(ui.run, browser_title='My App')
-    def ui_run_demo():
-        ui.label('page with custom title')
-
-        # ui.run(title='My App')
+    load_demo(ui.run)
 
     # HACK: switch color to white for the next demo
     demo_BROWSER_BGCOLOR = demo.BROWSER_BGCOLOR
@@ -649,7 +660,7 @@ def create_full() -> None:
         Pick any parameter as it is defined by the internally used [pywebview module](https://pywebview.flowrl.com/guide/api.html) 
         for the `webview.create_window` and `webview.start` functions.
         Note that these keyword arguments will take precedence over the parameters defined in ui.run.
-    ''')
+    ''', tab=lambda: ui.label('NiceGUI'))
     def native_mode_demo():
         from nicegui import app
 

+ 6 - 5
website/documentation_tools.py

@@ -65,15 +65,17 @@ def render_docstring(doc: str, with_params: bool = True) -> ui.html:
 
 class text_demo:
 
-    def __init__(self, title: str, explanation: str) -> None:
+    def __init__(self, title: str, explanation: str, tab: Optional[Union[str, Callable]] = None) -> None:
         self.title = title
         self.explanation = explanation
         self.make_menu_entry = True
+        self.tab = tab
 
     def __call__(self, f: Callable) -> Callable:
         subheading(self.title, make_menu_entry=self.make_menu_entry)
         ui.markdown(self.explanation).classes('bold-links arrow-links')
-        return demo()(f)
+        f.tab = self.tab
+        return demo(f)
 
 
 class intro_demo(text_demo):
@@ -85,9 +87,8 @@ class intro_demo(text_demo):
 
 class element_demo:
 
-    def __init__(self, element_class: Union[Callable, type], browser_title: Optional[str] = None) -> None:
+    def __init__(self, element_class: Union[Callable, type]) -> None:
         self.element_class = element_class
-        self.browser_title = browser_title
 
     def __call__(self, f: Callable, *, more_link: Optional[str] = None) -> Callable:
         doc = self.element_class.__doc__ or self.element_class.__init__.__doc__
@@ -95,7 +96,7 @@ class element_demo:
         with ui.column().classes('w-full mb-8 gap-2'):
             subheading(title, more_link=more_link)
             render_docstring(documentation, with_params=more_link is None)
-            return demo(browser_title=self.browser_title)(f)
+            return demo(f)
 
 
 def load_demo(api: Union[type, Callable]) -> None:

+ 13 - 0
website/more_documentation/grid_documentation.py

@@ -0,0 +1,13 @@
+from nicegui import ui
+
+
+def main_demo() -> None:
+    with ui.grid(columns=2):
+        ui.label('Name:')
+        ui.label('Tom')
+
+        ui.label('Age:')
+        ui.label('42')
+
+        ui.label('Height:')
+        ui.label('1.80m')

+ 13 - 0
website/more_documentation/input_documentation.py

@@ -1,8 +1,21 @@
 from nicegui import ui
 
+from ..documentation_tools import text_demo
+
 
 def main_demo() -> None:
     ui.input(label='Text', placeholder='start typing',
              on_change=lambda e: result.set_text('you typed: ' + e.value),
              validation={'Input too long': lambda value: len(value) < 20})
     result = ui.label()
+
+
+def more() -> None:
+
+    @text_demo('Auto complete input', '''
+        The `autocomplete` feature provides suggestions as you type, making input easier and faster.
+        The parameter `options` is a list of strings that contains the available options that will appear.
+    ''')
+    async def autocomplete_demo():
+        options = ['AutoComplete', 'NiceGUI', 'Awesome']
+        ui.input(label='Text', placeholder='start typing', autocomplete=options)

+ 58 - 0
website/more_documentation/run_documentation.py

@@ -1,7 +1,65 @@
 from nicegui import ui
 
+from ..documentation_tools import text_demo
+
 
 def main_demo() -> None:
     ui.label('page with custom title')
 
     # ui.run(title='My App')
+main_demo.tab = 'My App'
+
+
+def more() -> None:
+    @text_demo('Emoji favicon', '''
+        You can use an emoji as favicon.
+        This works in Chrome, Firefox and Safari.
+    ''', tab=lambda: ui.markdown('🚀&nbsp; NiceGUI'))
+    def emoji_favicon():
+        ui.label('NiceGUI Rocks!')
+
+        # ui.run(favicon='🚀')
+
+    @text_demo(
+        'Base64 favicon', '''
+        You can also use an base64-encoded image as favicon.
+    ''', tab=lambda: (
+            ui.image('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==')
+            .classes('w-4 h-4'),
+            ui.label('NiceGUI'),
+        ),
+    )
+    def base64_favicon():
+        ui.label('NiceGUI with a red dot!')
+
+        icon = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=='
+
+        # ui.run(favicon=icon)
+
+    @text_demo('SVG favicon', '''
+        And directly use an SVG as favicon.
+        Works in Chrome, Firefox and Safari.
+    ''', tab=lambda: (
+        ui.html('''
+            <svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+                <circle cx="100" cy="100" r="78" fill="#ffde34" stroke="black" stroke-width="3" />
+                <circle cx="80" cy="85" r="8" />
+                <circle cx="120" cy="85" r="8" />
+                <path d="m60,120 C75,150 125,150 140,120" style="fill:none; stroke:black; stroke-width:8; stroke-linecap:round" />
+            </svg>
+        ''').classes('w-4 h-4'),
+        ui.label('NiceGUI'),
+    ))
+    def svg_favicon():
+        ui.label('NiceGUI makes you smile!')
+
+        smiley = '''
+            <svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+                <circle cx="100" cy="100" r="78" fill="#ffde34" stroke="black" stroke-width="3" />
+                <circle cx="80" cy="85" r="8" />
+                <circle cx="120" cy="85" r="8" />
+                <path d="m60,120 C75,150 125,150 140,120" style="fill:none; stroke:black; stroke-width:8; stroke-linecap:round" />
+            </svg>
+        '''
+
+        # ui.run(favicon=smiley)

+ 10 - 0
website/more_documentation/video_documentation.py

@@ -1,6 +1,16 @@
 from nicegui import ui
+from website.documentation_tools import text_demo
 
 
 def main_demo() -> None:
     v = ui.video('https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/360/Big_Buck_Bunny_360_10s_1MB.mp4')
     v.on('ended', lambda _: ui.notify('Video playback completed'))
+
+
+def more() -> None:
+    @text_demo('Video start position', '''
+        This demo shows how to set the start position of a video.
+    ''')
+    def start_position_demo() -> None:
+        v = ui.video('https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/360/Big_Buck_Bunny_360_10s_1MB.mp4')
+        v.on('loadedmetadata', lambda: v.seek(5))

+ 4 - 3
website/star.py

@@ -5,7 +5,7 @@ from website import svg
 STYLE = '''
 <style>
     @keyframes star-tumble {
-          0% { transform: translateX(2em) rotate(144deg); }
+          0% { transform: translateX(6em) rotate(432deg); }
         100% { transform: translateX(0)   rotate(0);      }
     }
     @keyframes star-pulse {
@@ -43,9 +43,9 @@ STAR = '''
 '''
 
 
-def add_star() -> None:
+def add_star() -> ui.link:
     ui.add_head_html(STYLE)
-    with ui.link(target='https://github.com/zauberzeug/nicegui/').classes('star-container'):
+    with ui.link(target='https://github.com/zauberzeug/nicegui/').classes('star-container') as link:
         with Element('svg').props('viewBox="0 0 24 24"').classes('star'):
             Element('path').props('d="M23.555,8.729a1.505,1.505,0,0,0-1.406-.98H16.062a.5.5,0,0,1-.472-.334L13.405,1.222a1.5,1.5,0,0,0-2.81,0l-.005.016L8.41,7.415a.5.5,0,0,1-.471.334H1.85A1.5,1.5,0,0,0,.887,10.4l5.184,4.3a.5.5,0,0,1,.155.543L4.048,21.774a1.5,1.5,0,0,0,2.31,1.684l5.346-3.92a.5.5,0,0,1,.591,0l5.344,3.919a1.5,1.5,0,0,0,2.312-1.683l-2.178-6.535a.5.5,0,0,1,.155-.543l5.194-4.306A1.5,1.5,0,0,0,23.555,8.729Z"')
         with ui.tooltip('').classes('bg-[#486991] w-96 p-4'):
@@ -54,3 +54,4 @@ def add_star() -> None:
                 with ui.column().classes('p-2 gap-2'):
                     ui.label('Star us on GitHub!').classes('text-[180%]')
                     ui.label('And tell others about NiceGUI.').classes('text-[140%]')
+    return link