Browse Source

Merge commit '1aac7c09f1d7c68d0c34f8e53c3a206ec89d29f8' into session_data

Rodja Trappe 2 years ago
parent
commit
ac485ed359
47 changed files with 747 additions and 141 deletions
  1. 1 0
      .github/workflows/test.yml
  2. 57 0
      examples/lightbox/main.py
  3. 20 0
      examples/modularization/example_c.py
  4. 2 7
      examples/modularization/example_pages.py
  5. 5 1
      examples/modularization/main.py
  6. 1 1
      examples/modularization/theme.py
  7. 39 37
      examples/todo_list/main.py
  8. 5 3
      fetch_tailwind.py
  9. 1 0
      main.py
  10. 2 0
      mypy.ini
  11. 2 1
      nicegui/__init__.py
  12. 43 0
      nicegui/api_router.py
  13. 1 1
      nicegui/app.py
  14. 3 1
      nicegui/background_tasks.py
  15. 1 1
      nicegui/binding.py
  16. 2 2
      nicegui/client.py
  17. 2 1
      nicegui/element.py
  18. 3 2
      nicegui/elements/aggrid.py
  19. 2 1
      nicegui/elements/input.py
  20. 12 3
      nicegui/elements/link.py
  21. 4 4
      nicegui/elements/mixins/value_element.py
  22. 3 2
      nicegui/elements/mixins/visibility.py
  23. 4 4
      nicegui/elements/scene_object3d.py
  24. 7 5
      nicegui/elements/upload.py
  25. 2 2
      nicegui/event_listener.py
  26. 2 2
      nicegui/events.py
  27. 31 15
      nicegui/favicon.py
  28. 2 2
      nicegui/functions/javascript.py
  29. 6 4
      nicegui/functions/refreshable.py
  30. 8 3
      nicegui/functions/timer.py
  31. 2 2
      nicegui/globals.py
  32. 122 1
      nicegui/native.py
  33. 63 9
      nicegui/native_mode.py
  34. 12 3
      nicegui/nicegui.py
  35. 3 3
      nicegui/outbox.py
  36. 13 3
      nicegui/page.py
  37. 17 1
      nicegui/run.py
  38. 5 3
      nicegui/tailwind.py
  39. 2 0
      tests/conftest.py
  40. 2 1
      tests/requirements.txt
  41. 31 0
      tests/test_api_router.py
  42. 92 0
      tests/test_favicon.py
  43. 18 0
      tests/test_link.py
  44. 9 3
      website/demo.py
  45. 13 7
      website/documentation.py
  46. 40 0
      website/more_documentation/link_documentation.py
  47. 30 0
      website/more_documentation/page_documentation.py

+ 1 - 0
.github/workflows/test.yml

@@ -26,6 +26,7 @@ jobs:
           poetry install
           poetry install
           # install packages to run the examples
           # install packages to run the examples
           pip install opencv-python opencv-contrib-python-headless httpx replicate langchain openai simpy
           pip install opencv-python opencv-contrib-python-headless httpx replicate langchain openai simpy
+          pip install -r tests/requirements.txt
           # try fix issue with importlib_resources
           # try fix issue with importlib_resources
           pip install importlib-resources
           pip install importlib-resources
       - name: test startup
       - name: test startup

+ 57 - 0
examples/lightbox/main.py

@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+from typing import List
+
+import httpx
+
+from nicegui import events, ui
+
+
+class Lightbox:
+    """A thumbnail gallery where each image can be clicked to enlarge.
+    Inspired by https://lokeshdhakar.com/projects/lightbox2/.
+    """
+
+    def __init__(self) -> None:
+        with ui.dialog().props('maximized').classes('bg-black') as self.dialog:
+            ui.keyboard(self._on_key)
+            self.large_image = ui.image().props('no-spinner')
+        self.image_list: List[str] = []
+
+    def add_image(self, thumb_url: str, orig_url: str) -> ui.image:
+        """Place a thumbnail image in the UI and make it clickable to enlarge."""
+        self.image_list.append(orig_url)
+        with ui.button(on_click=lambda: self._open(orig_url)).props('flat dense square'):
+            return ui.image(thumb_url)
+
+    def _on_key(self, event_args: events.KeyEventArguments) -> None:
+        if not event_args.action.keydown:
+            return
+        if event_args.key.escape:
+            self.dialog.close()
+        image_index = self.image_list.index(self.large_image.source)
+        if event_args.key.arrow_left and image_index > 0:
+            self._open(self.image_list[image_index - 1])
+        if event_args.key.arrow_right and image_index < len(self.image_list) - 1:
+            self._open(self.image_list[image_index + 1])
+
+    def _open(self, url: str) -> None:
+        self.large_image.set_source(url)
+        self.dialog.open()
+
+
+@ui.page('/')
+async def page():
+    lightbox = Lightbox()
+    async with httpx.AsyncClient() as client:  # using async httpx instead of sync requests to avoid blocking the event loop
+        images = await client.get('https://picsum.photos/v2/list?page=4&limit=30')
+    with ui.row().classes('w-full'):
+        for image in images.json():  # picsum returns a list of images as json data
+            # we can use the image ID to construct the image URLs
+            image_base_url = f'https://picsum.photos/id/{image["id"]}'
+            # the lightbox allows us to add images which can be opened in a full screen dialog
+            lightbox.add_image(
+                thumb_url=f'{image_base_url}/300/200',
+                orig_url=f'{image_base_url}/{image["width"]}/{image["height"]}',
+            ).classes('w-[300px] h-[200px]')
+
+ui.run()

+ 20 - 0
examples/modularization/example_c.py

@@ -0,0 +1,20 @@
+import theme
+
+from nicegui import APIRouter, ui
+
+router = APIRouter(prefix='/c')
+
+
+@router.page('/')
+def example_page():
+    with theme.frame('- Example C -'):
+        ui.label('Example C').classes('text-h4 text-grey-8')
+        for i in range(1, 4):
+            ui.link(f'Item {i}', f'/c/items/{i}').classes('text-xl text-grey-8')
+
+
+@router.page('/items/{id}', dark=True)
+def item(id: str):
+    with theme.frame(f'- Example C{id} -'):
+        ui.label(f'Item  #{id}').classes('text-h4 text-grey-8')
+        ui.link('go back', router.prefix).classes('text-xl text-grey-8')

+ 2 - 7
examples/modularization/example_pages.py

@@ -6,16 +6,11 @@ from nicegui import ui
 def create() -> None:
 def create() -> None:
 
 
     @ui.page('/a')
     @ui.page('/a')
-    def example_page():
+    def example_page_a():
         with theme.frame('- Example A -'):
         with theme.frame('- Example A -'):
             ui.label('Example A').classes('text-h4 text-grey-8')
             ui.label('Example A').classes('text-h4 text-grey-8')
 
 
     @ui.page('/b')
     @ui.page('/b')
-    def example_page():
+    def example_page_b():
         with theme.frame('- Example B -'):
         with theme.frame('- Example B -'):
             ui.label('Example B').classes('text-h4 text-grey-8')
             ui.label('Example B').classes('text-h4 text-grey-8')
-
-    @ui.page('/c')
-    def example_page():
-        with theme.frame('- Example C -'):
-            ui.label('Example C').classes('text-h4 text-grey-8')

+ 5 - 1
examples/modularization/main.py

@@ -1,9 +1,10 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
+import example_c
 import example_pages
 import example_pages
 import home_page
 import home_page
 import theme
 import theme
 
 
-from nicegui import ui
+from nicegui import app, ui
 
 
 
 
 # here we use our custom page decorator directly and just put the content creation into a separate function
 # here we use our custom page decorator directly and just put the content creation into a separate function
@@ -16,4 +17,7 @@ def index_page() -> None:
 # this call shows that you can also move the whole page creation into a separate file
 # this call shows that you can also move the whole page creation into a separate file
 example_pages.create()
 example_pages.create()
 
 
+# we can also use the APIRouter as described in https://nicegui.io/documentation/page#modularize_with_apirouter
+app.include_router(example_c.router)
+
 ui.run(title='Modularization Example')
 ui.run(title='Modularization Example')

+ 1 - 1
examples/modularization/theme.py

@@ -14,5 +14,5 @@ def frame(navtitle: str):
         ui.label(navtitle)
         ui.label(navtitle)
         with ui.row():
         with ui.row():
             menu()
             menu()
-    with ui.row().classes('absolute-center'):
+    with ui.column().classes('absolute-center items-center'):
         yield
         yield

+ 39 - 37
examples/todo_list/main.py

@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
-from dataclasses import dataclass
-from typing import List
+from dataclasses import dataclass, field
+from typing import Any, Callable, List
 
 
 from nicegui import ui
 from nicegui import ui
 
 
@@ -8,58 +8,60 @@ from nicegui import ui
 @dataclass
 @dataclass
 class TodoItem:
 class TodoItem:
     name: str
     name: str
+    on_change: Callable
     done: bool = False
     done: bool = False
 
 
+    def rename(self, new_name: str) -> None:
+        self.name = new_name
 
 
-items: List[TodoItem] = [
-    TodoItem('Buy milk', done=True),
-    TodoItem('Clean the house'),
-    TodoItem('Call mom'),
-]
+    def __setattr__(self, name: str, value: Any) -> None:
+        super().__setattr__(name, value)
+        if hasattr(self, 'on_change'):
+            self.on_change()
 
 
 
 
-def add(name: str) -> None:
-    items.append(TodoItem(name))
-    add_input.value = None
-    render_list.refresh()
-
-
-def remove(item: TodoItem) -> None:
-    items.remove(item)
-    render_list.refresh()
-
-
-def toggle(item: TodoItem) -> None:
-    item.done = not item.done
-    render_list.refresh()
+@dataclass
+class ToDoList:
+    title: str
+    on_change: Callable
+    items: List[TodoItem] = field(default_factory=list)
 
 
+    def add(self, name: str, done: bool = False) -> None:
+        self.items.append(TodoItem(name, self.on_change, done))
+        self.on_change()
 
 
-def rename(item: TodoItem, name: str) -> None:
-    item.name = name
-    render_list.refresh()
+    def remove(self, item: TodoItem) -> None:
+        self.items.remove(item)
+        self.on_change()
 
 
 
 
 @ui.refreshable
 @ui.refreshable
-def render_list():
-    if not items:
-        ui.label('List is empty.')
+def todo_ui():
+    if not todos.items:
+        ui.label('List is empty.').classes('mx-auto')
         return
         return
-    ui.linear_progress(sum(item.done for item in items) / len(items), show_value=False)
+    ui.linear_progress(sum(item.done for item in todos.items) / len(todos.items), show_value=False)
     with ui.row().classes('justify-center w-full'):
     with ui.row().classes('justify-center w-full'):
-        ui.label(f'Completed: {sum(item.done for item in items)}')
-        ui.label(f'Remaining: {sum(not item.done for item in items)}')
-    for item in items:
+        ui.label(f'Completed: {sum(item.done for item in todos.items)}')
+        ui.label(f'Remaining: {sum(not item.done for item in todos.items)}')
+    for item in todos.items:
         with ui.row().classes('items-center'):
         with ui.row().classes('items-center'):
-            ui.checkbox(value=item.done, on_change=lambda _, item=item: toggle(item))
+            ui.checkbox().bind_value(item, 'done')
             input = ui.input(value=item.name).classes('flex-grow')
             input = ui.input(value=item.name).classes('flex-grow')
-            input.on('keydown.enter', lambda _, item=item, input=input: rename(item, input.value))
-            ui.button(on_click=lambda _, item=item: remove(item)).props('flat fab-mini icon=delete color=grey')
+            input.on('keydown.enter', lambda _, item=item, input=input: item.rename(input.value))
+            ui.button(on_click=lambda _, item=item: todos.remove(item)).props('flat fab-mini icon=delete color=grey')
+
 
 
+todos = ToDoList('My Weekend', on_change=todo_ui.refresh)
+todos.add('Order pizza', done=True)
+todos.add('New NiceGUI Release')
+todos.add('Clean the house')
+todos.add('Call mom')
 
 
 with ui.card().classes('w-80 items-stretch'):
 with ui.card().classes('w-80 items-stretch'):
-    ui.label('Todo list').classes('text-semibold text-2xl')
-    render_list()
+    ui.label().bind_text_from(todos, 'title').classes('text-semibold text-2xl')
+    todo_ui()
     add_input = ui.input('New item').classes('mx-12')
     add_input = ui.input('New item').classes('mx-12')
-    add_input.on('keydown.enter', lambda: add(add_input.value))
+    add_input.on('keydown.enter', lambda: (todos.add(add_input.value), add_input.set_value('')))
 
 
 ui.run()
 ui.run()

+ 5 - 3
fetch_tailwind.py

@@ -94,7 +94,7 @@ for property in properties:
 with open(Path(__file__).parent / 'nicegui' / 'tailwind.py', 'w') as f:
 with open(Path(__file__).parent / 'nicegui' / 'tailwind.py', 'w') as f:
     f.write('from __future__ import annotations\n')
     f.write('from __future__ import annotations\n')
     f.write('\n')
     f.write('\n')
-    f.write('from typing import TYPE_CHECKING, List, Optional, overload\n')
+    f.write('from typing import TYPE_CHECKING, List, Optional, Union, overload\n')
     f.write('\n')
     f.write('\n')
     f.write('if TYPE_CHECKING:\n')
     f.write('if TYPE_CHECKING:\n')
     f.write('    from .element import Element\n')
     f.write('    from .element import Element\n')
@@ -116,10 +116,10 @@ with open(Path(__file__).parent / 'nicegui' / 'tailwind.py', 'w') as f:
     f.write('class Tailwind:\n')
     f.write('class Tailwind:\n')
     f.write('\n')
     f.write('\n')
     f.write("    def __init__(self, _element: Optional['Element'] = None) -> None:\n")
     f.write("    def __init__(self, _element: Optional['Element'] = None) -> None:\n")
-    f.write('        self.element = _element or PseudoElement()\n')
+    f.write('        self.element: Union[PseudoElement, Element] = PseudoElement() if _element is None else _element\n')
     f.write('\n')
     f.write('\n')
     f.write('    @overload\n')
     f.write('    @overload\n')
-    f.write('    def __call__(self, Tailwind) -> Tailwind:\n')
+    f.write('    def __call__(self, tailwind: Tailwind) -> Tailwind:\n')
     f.write('        ...\n')
     f.write('        ...\n')
     f.write('\n')
     f.write('\n')
     f.write('    @overload\n')
     f.write('    @overload\n')
@@ -127,6 +127,8 @@ with open(Path(__file__).parent / 'nicegui' / 'tailwind.py', 'w') as f:
     f.write('        ...\n')
     f.write('        ...\n')
     f.write('\n')
     f.write('\n')
     f.write('    def __call__(self, *args) -> Tailwind:\n')
     f.write('    def __call__(self, *args) -> Tailwind:\n')
+    f.write('        if not args:\n')
+    f.write('           return self\n')
     f.write('        if isinstance(args[0], Tailwind):\n')
     f.write('        if isinstance(args[0], Tailwind):\n')
     f.write('            args[0].apply(self.element)\n')
     f.write('            args[0].apply(self.element)\n')
     f.write('        else:\n')
     f.write('        else:\n')

+ 1 - 0
main.py

@@ -279,6 +279,7 @@ async def index_page(client: Client) -> None:
             example_link('Chat with AI', 'a simple chat app with AI')
             example_link('Chat with AI', 'a simple chat app with AI')
             example_link('SQLite Database', 'CRUD operations on a SQLite database')
             example_link('SQLite Database', 'CRUD operations on a SQLite database')
             example_link('Pandas DataFrame', 'displays an editable [pandas](https://pandas.pydata.org) DataFrame')
             example_link('Pandas DataFrame', 'displays an editable [pandas](https://pandas.pydata.org) DataFrame')
+            example_link('Lightbox', 'A thumbnail gallery where each image can be clicked to enlarge')
 
 
     with ui.row().classes('bg-primary w-full min-h-screen mt-16'):
     with ui.row().classes('bg-primary w-full min-h-screen mt-16'):
         link_target('why')
         link_target('why')

+ 2 - 0
mypy.ini

@@ -0,0 +1,2 @@
+[mypy]
+ignore_missing_imports = True

+ 2 - 1
nicegui/__init__.py

@@ -3,9 +3,10 @@ try:
 except ModuleNotFoundError:
 except ModuleNotFoundError:
     import importlib_metadata
     import importlib_metadata
 
 
-__version__ = importlib_metadata.version('nicegui')
+__version__: str = importlib_metadata.version('nicegui')
 
 
 from . import elements, globals, ui
 from . import elements, globals, ui
+from .api_router import APIRouter
 from .client import Client
 from .client import Client
 from .nicegui import app
 from .nicegui import app
 from .tailwind import Tailwind
 from .tailwind import Tailwind

+ 43 - 0
nicegui/api_router.py

@@ -0,0 +1,43 @@
+from typing import Callable, Optional
+
+import fastapi
+
+from .page import page as ui_page
+
+
+class APIRouter(fastapi.APIRouter):
+
+    def page(self,
+             path: str, *,
+             title: Optional[str] = None,
+             viewport: Optional[str] = None,
+             favicon: Optional[str] = None,
+             dark: Optional[bool] = ...,
+             response_timeout: float = 3.0,
+             **kwargs,
+             ) -> Callable:
+        """Page
+
+        Creates a new page at the given route.
+        Each user will see a new instance of the page.
+        This means it is private to the user and not shared with others
+        (as it is done `when placing elements outside of a page decorator <https://nicegui.io/documentation#auto-index_page>`_).
+
+        :param path: route of the new page (path must start with '/')
+        :param title: optional page title
+        :param viewport: optional viewport meta tag content
+        :param favicon: optional relative filepath or absolute URL to a favicon (default: `None`, NiceGUI icon will be used)
+        :param dark: whether to use Quasar's dark mode (defaults to `dark` argument of `run` command)
+        :param response_timeout: maximum time for the decorated function to build the page (default: 3.0)
+        :param kwargs: additional keyword arguments passed to FastAPI's @app.get method
+        """
+        return ui_page(
+            path,
+            title=title,
+            viewport=viewport,
+            favicon=favicon,
+            dark=dark,
+            response_timeout=response_timeout,
+            api_router=self,
+            **kwargs
+        )

+ 1 - 1
nicegui/app.py

@@ -45,7 +45,7 @@ class App(FastAPI):
         """
         """
         globals.shutdown_handlers.append(handler)
         globals.shutdown_handlers.append(handler)
 
 
-    def on_exception(self, handler: Union[Callable, Awaitable]) -> None:
+    def on_exception(self, handler: Callable) -> None:
         """Called when an exception occurs.
         """Called when an exception occurs.
 
 
         The callback has an optional parameter of `Exception`.
         The callback has an optional parameter of `Exception`.

+ 3 - 1
nicegui/background_tasks.py

@@ -21,7 +21,9 @@ def create(coroutine: Awaitable[T], *, name: str = 'unnamed task') -> 'asyncio.T
     Also a reference to the task is kept until it is done, so that the task is not garbage collected mid-execution.
     Also a reference to the task is kept until it is done, so that the task is not garbage collected mid-execution.
     See https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task.
     See https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task.
     """
     """
-    task = globals.loop.create_task(coroutine, name=name) if name_supported else globals.loop.create_task(coroutine)
+    assert globals.loop is not None
+    task: asyncio.Task = \
+        globals.loop.create_task(coroutine, name=name) if name_supported else globals.loop.create_task(coroutine)
     task.add_done_callback(_handle_task_result)
     task.add_done_callback(_handle_task_result)
     running_tasks.add(task)
     running_tasks.add(task)
     task.add_done_callback(running_tasks.discard)
     task.add_done_callback(running_tasks.discard)

+ 1 - 1
nicegui/binding.py

@@ -25,7 +25,7 @@ def set_attribute(obj: Union[object, Dict], name: str, value: Any) -> None:
         setattr(obj, name, value)
         setattr(obj, name, value)
 
 
 
 
-async def loop():
+async def loop() -> None:
     while True:
     while True:
         visited: Set[Tuple[int, str]] = set()
         visited: Set[Tuple[int, str]] = set()
         t = time.time()
         t = time.time()

+ 2 - 2
nicegui/client.py

@@ -40,7 +40,7 @@ class Client:
                 with Element('q-page'):
                 with Element('q-page'):
                     self.content = Element('div').classes('nicegui-content')
                     self.content = Element('div').classes('nicegui-content')
 
 
-        self.waiting_javascript_commands: Dict[str, str] = {}
+        self.waiting_javascript_commands: Dict[str, Any] = {}
 
 
         self.head_html = ''
         self.head_html = ''
         self.body_html = ''
         self.body_html = ''
@@ -108,7 +108,7 @@ class Client:
         self.is_waiting_for_disconnect = False
         self.is_waiting_for_disconnect = False
 
 
     async def run_javascript(self, code: str, *,
     async def run_javascript(self, code: str, *,
-                             respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Optional[str]:
+                             respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Optional[Any]:
         """Execute JavaScript on the client.
         """Execute JavaScript on the client.
 
 
         The client connection must be established before this method is called.
         The client connection must be established before this method is called.

+ 2 - 1
nicegui/element.py

@@ -80,7 +80,7 @@ class Element(Visibility):
             for child in slot:
             for child in slot:
                 yield child
                 yield child
 
 
-    def _collect_slot_dict(self) -> Dict[str, List[int]]:
+    def _collect_slot_dict(self) -> Dict[str, Any]:
         return {
         return {
             name: {'template': slot.template, 'ids': [child.id for child in slot]}
             name: {'template': slot.template, 'ids': [child.id for child in slot]}
             for name, slot in self.slots.items()
             for name, slot in self.slots.items()
@@ -278,6 +278,7 @@ class Element(Visibility):
         :param target_container: container to move the element to (default: the parent container)
         :param target_container: container to move the element to (default: the parent container)
         :param target_index: index within the target slot (default: append to the end)
         :param target_index: index within the target slot (default: append to the end)
         """
         """
+        assert self.parent_slot is not None
         self.parent_slot.children.remove(self)
         self.parent_slot.children.remove(self)
         self.parent_slot.parent.update()
         self.parent_slot.parent.update()
         target_container = target_container or self.parent_slot.parent
         target_container = target_container or self.parent_slot.parent

+ 3 - 2
nicegui/elements/aggrid.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
-from typing import Dict, List, Optional
+from typing import Dict, List, Optional, cast
 
 
 from ..dependencies import register_component
 from ..dependencies import register_component
 from ..element import Element
 from ..element import Element
@@ -67,7 +67,8 @@ class AgGrid(Element):
 
 
         :return: list of selected row data
         :return: list of selected row data
         """
         """
-        return await run_javascript(f'return getElement({self.id}).gridOptions.api.getSelectedRows();')
+        result = await run_javascript(f'return getElement({self.id}).gridOptions.api.getSelectedRows();')
+        return cast(List[Dict], result)
 
 
     async def get_selected_row(self) -> Optional[Dict]:
     async def get_selected_row(self) -> Optional[Dict]:
         """Get the single currently selected row.
         """Get the single currently selected row.

+ 2 - 1
nicegui/elements/input.py

@@ -58,9 +58,10 @@ class Input(ValueElement, DisableableElement):
             def find_autocompletion() -> Optional[str]:
             def find_autocompletion() -> Optional[str]:
                 if self.value:
                 if self.value:
                     needle = str(self.value).casefold()
                     needle = str(self.value).casefold()
-                    for item in autocomplete:
+                    for item in autocomplete or []:
                         if item.casefold().startswith(needle):
                         if item.casefold().startswith(needle):
                             return item
                             return item
+                return None  # required by mypy
 
 
             def autocomplete_input() -> None:
             def autocomplete_input() -> None:
                 match = find_autocompletion() or ''
                 match = find_autocompletion() or ''

+ 12 - 3
nicegui/elements/link.py

@@ -10,7 +10,11 @@ register_component('link', __file__, 'link.js')
 
 
 class Link(TextElement):
 class Link(TextElement):
 
 
-    def __init__(self, text: str = '', target: Union[Callable[..., Any], str] = '#', new_tab: bool = False) -> None:
+    def __init__(self,
+                 text: str = '',
+                 target: Union[Callable[..., Any], str, Element] = '#',
+                 new_tab: bool = False,
+                 ) -> None:
         """Link
         """Link
 
 
         Create a hyperlink.
         Create a hyperlink.
@@ -19,11 +23,16 @@ class Link(TextElement):
         and link to it with `ui.link(target="#name")`.
         and link to it with `ui.link(target="#name")`.
 
 
         :param text: display text
         :param text: display text
-        :param target: page function or string that is a an absolute URL or relative path from base URL
+        :param target: page function, NiceGUI element on the same page or string that is a an absolute URL or relative path from base URL
         :param new_tab: open link in new tab (default: False)
         :param new_tab: open link in new tab (default: False)
         """
         """
         super().__init__(tag='link', text=text)
         super().__init__(tag='link', text=text)
-        self._props['href'] = target if isinstance(target, str) else globals.page_routes[target]
+        if isinstance(target, str):
+            self._props['href'] = target
+        elif isinstance(target, Element):
+            self._props['href'] = f'#{target.id}'
+        elif isinstance(target, Callable):
+            self._props['href'] = globals.page_routes[target]
         self._props['target'] = '_blank' if new_tab else '_self'
         self._props['target'] = '_blank' if new_tab else '_self'
         self._classes = ['nicegui-link']
         self._classes = ['nicegui-link']
 
 

+ 4 - 4
nicegui/elements/mixins/value_element.py

@@ -1,4 +1,4 @@
-from typing import Any, Callable, Dict, Optional
+from typing import Any, Callable, Dict, List, Optional
 
 
 from typing_extensions import Self
 from typing_extensions import Self
 
 
@@ -8,9 +8,9 @@ from ...events import ValueChangeEventArguments, handle_event
 
 
 
 
 class ValueElement(Element):
 class ValueElement(Element):
-    VALUE_PROP = 'model-value'
-    EVENT_ARGS = ['value']
-    LOOPBACK = True
+    VALUE_PROP: str = 'model-value'
+    EVENT_ARGS: Optional[List[str]] = ['value']
+    LOOPBACK: bool = True
     value = BindableProperty(on_change=lambda sender, value: sender.on_value_change(value))
     value = BindableProperty(on_change=lambda sender, value: sender.on_value_change(value))
 
 
     def __init__(self, *,
     def __init__(self, *,

+ 3 - 2
nicegui/elements/mixins/visibility.py

@@ -1,4 +1,4 @@
-from typing import TYPE_CHECKING, Any, Callable
+from typing import TYPE_CHECKING, Any, Callable, cast
 
 
 from typing_extensions import Self
 from typing_extensions import Self
 
 
@@ -79,11 +79,12 @@ class Visibility:
         """
         """
         self.visible = visible
         self.visible = visible
 
 
-    def on_visibility_change(self: 'Element', visible: str) -> None:
+    def on_visibility_change(self, visible: str) -> None:
         """Called when the visibility of this element changes.
         """Called when the visibility of this element changes.
 
 
         :param visible: Whether the element should be visible.
         :param visible: Whether the element should be visible.
         """
         """
+        self = cast('Element', self)
         if visible and 'hidden' in self._classes:
         if visible and 'hidden' in self._classes:
             self._classes.remove('hidden')
             self._classes.remove('hidden')
             self.update()
             self.update()

+ 4 - 4
nicegui/elements/scene_object3d.py

@@ -1,12 +1,12 @@
 import uuid
 import uuid
-from typing import TYPE_CHECKING, Any, List, Optional
+from typing import TYPE_CHECKING, Any, List, Optional, Union, cast
 
 
 import numpy as np
 import numpy as np
 
 
 from .. import globals
 from .. import globals
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
-    from .scene import Scene
+    from .scene import Scene, SceneObject
 
 
 
 
 class Object3D:
 class Object3D:
@@ -15,9 +15,9 @@ class Object3D:
         self.type = type
         self.type = type
         self.id = str(uuid.uuid4())
         self.id = str(uuid.uuid4())
         self.name: Optional[str] = None
         self.name: Optional[str] = None
-        self.scene: 'Scene' = globals.get_slot().parent
+        self.scene: 'Scene' = cast('Scene', globals.get_slot().parent)
         self.scene.objects[self.id] = self
         self.scene.objects[self.id] = self
-        self.parent: Object3D = self.scene.stack[-1]
+        self.parent: Union[Object3D, SceneObject] = self.scene.stack[-1]
         self.args: List = list(args)
         self.args: List = list(args)
         self.color: str = '#ffffff'
         self.color: str = '#ffffff'
         self.opacity: float = 1.0
         self.opacity: float = 1.0

+ 7 - 5
nicegui/elements/upload.py

@@ -1,6 +1,7 @@
-from typing import Any, Callable, Optional
+from typing import Any, Callable, Dict, Optional
 
 
-from fastapi import Request, Response
+from fastapi import Request
+from starlette.datastructures import UploadFile
 
 
 from ..dependencies import register_component
 from ..dependencies import register_component
 from ..events import EventArguments, UploadEventArguments, handle_event
 from ..events import EventArguments, UploadEventArguments, handle_event
@@ -51,14 +52,15 @@ class Upload(DisableableElement):
             self._props['max-files'] = max_files
             self._props['max-files'] = max_files
 
 
         @app.post(self._props['url'])
         @app.post(self._props['url'])
-        async def upload_route(request: Request) -> Response:
+        async def upload_route(request: Request) -> Dict[str, str]:
             for data in (await request.form()).values():
             for data in (await request.form()).values():
+                assert isinstance(data, UploadFile)
                 args = UploadEventArguments(
                 args = UploadEventArguments(
                     sender=self,
                     sender=self,
                     client=self.client,
                     client=self.client,
                     content=data.file,
                     content=data.file,
-                    name=data.filename,
-                    type=data.content_type,
+                    name=data.filename or '',
+                    type=data.content_type or '',
                 )
                 )
                 handle_event(on_upload, args)
                 handle_event(on_upload, args)
             return {'upload': 'success'}
             return {'upload': 'success'}

+ 2 - 2
nicegui/event_listener.py

@@ -1,6 +1,6 @@
 import uuid
 import uuid
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
-from typing import Any, Callable, Dict, List
+from typing import Any, Callable, Dict, List, Optional
 
 
 from fastapi import Request
 from fastapi import Request
 
 
@@ -12,7 +12,7 @@ class EventListener:
     id: str = field(init=False)
     id: str = field(init=False)
     element_id: int
     element_id: int
     type: str
     type: str
-    args: List[str]
+    args: Optional[List[str]]
     handler: Callable
     handler: Callable
     throttle: float
     throttle: float
     leading_events: bool
     leading_events: bool

+ 2 - 2
nicegui/events.py

@@ -1,5 +1,5 @@
 from dataclasses import dataclass
 from dataclasses import dataclass
-from inspect import signature
+from inspect import Parameter, signature
 from typing import TYPE_CHECKING, Any, BinaryIO, Callable, Dict, List, Optional, Union
 from typing import TYPE_CHECKING, Any, BinaryIO, Callable, Dict, List, Optional, Union
 
 
 from . import background_tasks, globals
 from . import background_tasks, globals
@@ -274,7 +274,7 @@ def handle_event(handler: Optional[Callable[..., Any]],
     try:
     try:
         if handler is None:
         if handler is None:
             return
             return
-        no_arguments = not signature(handler).parameters
+        no_arguments = not any(p.default is Parameter.empty for p in signature(handler).parameters.values())
         sender = arguments.sender if isinstance(arguments, EventArguments) else sender
         sender = arguments.sender if isinstance(arguments, EventArguments) else sender
         assert sender is not None and sender.parent_slot is not None
         assert sender is not None and sender.parent_slot is not None
         with sender.parent_slot:
         with sender.parent_slot:

+ 31 - 15
nicegui/favicon.py

@@ -1,8 +1,10 @@
+import base64
+import io
 import urllib.parse
 import urllib.parse
 from pathlib import Path
 from pathlib import Path
-from typing import TYPE_CHECKING, Optional
+from typing import TYPE_CHECKING, Optional, Tuple
 
 
-from fastapi.responses import FileResponse
+from fastapi.responses import FileResponse, Response, StreamingResponse
 
 
 from . import __version__, globals
 from . import __version__, globals
 
 
@@ -11,32 +13,41 @@ if TYPE_CHECKING:
 
 
 
 
 def create_favicon_route(path: str, favicon: Optional[str]) -> None:
 def create_favicon_route(path: str, favicon: Optional[str]) -> None:
-    if favicon and (is_remote_url(favicon) or is_char(favicon)):
-        return
-    fallback = Path(__file__).parent / 'static' / 'favicon.ico'
-    path = f'{"" if path == "/" else path}/favicon.ico'
-    globals.app.remove_route(path)
-    globals.app.add_route(path, lambda _: FileResponse(favicon or globals.favicon or fallback))
+    if favicon and Path(favicon).exists():
+        globals.app.add_route(f'{path}/favicon.ico', lambda _: FileResponse(favicon))
 
 
 
 
 def get_favicon_url(page: 'page', prefix: str) -> str:
 def get_favicon_url(page: 'page', prefix: str) -> str:
     favicon = page.favicon or globals.favicon
     favicon = page.favicon or globals.favicon
-    if favicon and is_remote_url(favicon):
-        return favicon
-    elif not favicon:
+    if not favicon:
         return f'{prefix}/_nicegui/{__version__}/static/favicon.ico'
         return f'{prefix}/_nicegui/{__version__}/static/favicon.ico'
+    favicon = str(favicon)
+    if is_remote_url(favicon):
+        return favicon
     elif is_data_url(favicon):
     elif is_data_url(favicon):
         return favicon
         return favicon
     elif is_svg(favicon):
     elif is_svg(favicon):
         return svg_to_data_url(favicon)
         return svg_to_data_url(favicon)
     elif is_char(favicon):
     elif is_char(favicon):
-        return char_to_data_url(favicon)
+        return svg_to_data_url(char_to_svg(favicon))
     elif page.path == '/':
     elif page.path == '/':
         return f'{prefix}/favicon.ico'
         return f'{prefix}/favicon.ico'
     else:
     else:
         return f'{prefix}{page.path}/favicon.ico'
         return f'{prefix}{page.path}/favicon.ico'
 
 
 
 
+def get_favicon_response() -> Response:
+    if is_svg(globals.favicon):
+        return Response(globals.favicon, media_type='image/svg+xml')
+    elif is_data_url(globals.favicon):
+        media_type, bytes = data_url_to_bytes(globals.favicon)
+        return StreamingResponse(io.BytesIO(bytes), media_type=media_type)
+    elif is_char(globals.favicon):
+        return Response(char_to_svg(globals.favicon), media_type='image/svg+xml')
+    else:
+        raise ValueError(f'invalid favicon: {globals.favicon}')
+
+
 def is_remote_url(favicon: str) -> bool:
 def is_remote_url(favicon: str) -> bool:
     return favicon.startswith('http://') or favicon.startswith('https://')
     return favicon.startswith('http://') or favicon.startswith('https://')
 
 
@@ -53,8 +64,8 @@ def is_data_url(favicon: str) -> bool:
     return favicon.startswith('data:')
     return favicon.startswith('data:')
 
 
 
 
-def char_to_data_url(char: str) -> str:
-    svg = f'''
+def char_to_svg(char: str) -> str:
+    return f'''
         <svg viewBox="0 0 128 128" width="128" height="128" xmlns="http://www.w3.org/2000/svg" >
         <svg viewBox="0 0 128 128" width="128" height="128" xmlns="http://www.w3.org/2000/svg" >
             <style>
             <style>
                 @supports (-moz-appearance:none) {{
                 @supports (-moz-appearance:none) {{
@@ -70,9 +81,14 @@ def char_to_data_url(char: str) -> str:
             <text y=".9em" font-size="128" font-family="Georgia, sans-serif">{char}</text>
             <text y=".9em" font-size="128" font-family="Georgia, sans-serif">{char}</text>
         </svg>
         </svg>
     '''
     '''
-    return svg_to_data_url(svg)
 
 
 
 
 def svg_to_data_url(svg: str) -> str:
 def svg_to_data_url(svg: str) -> str:
     svg_urlencoded = urllib.parse.quote(svg)
     svg_urlencoded = urllib.parse.quote(svg)
     return f'data:image/svg+xml,{svg_urlencoded}'
     return f'data:image/svg+xml,{svg_urlencoded}'
+
+
+def data_url_to_bytes(data_url: str) -> Tuple[str, bytes]:
+    media_type, base64_image = data_url.split(",", 1)
+    media_type = media_type.split(":")[1].split(";")[0]
+    return media_type, base64.b64decode(base64_image)

+ 2 - 2
nicegui/functions/javascript.py

@@ -1,10 +1,10 @@
-from typing import Optional
+from typing import Any, Optional
 
 
 from .. import globals
 from .. import globals
 
 
 
 
 async def run_javascript(code: str, *,
 async def run_javascript(code: str, *,
-                         respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Optional[str]:
+                         respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Optional[Any]:
     """Run JavaScript
     """Run JavaScript
 
 
     This function runs arbitrary JavaScript code on a page that is executed in the browser.
     This function runs arbitrary JavaScript code on a page that is executed in the browser.

+ 6 - 4
nicegui/functions/refreshable.py

@@ -1,5 +1,5 @@
 from dataclasses import dataclass
 from dataclasses import dataclass
-from typing import Any, Callable, Dict, List
+from typing import Any, Awaitable, Callable, Dict, List, Tuple, Union
 
 
 from typing_extensions import Self
 from typing_extensions import Self
 
 
@@ -15,10 +15,10 @@ register_component('refreshable', __file__, 'refreshable.js')
 class RefreshableTarget:
 class RefreshableTarget:
     container: Element
     container: Element
     instance: Any
     instance: Any
-    args: List[Any]
+    args: Tuple[Any, ...]
     kwargs: Dict[str, Any]
     kwargs: Dict[str, Any]
 
 
-    def run(self, func: Callable[..., Any]) -> None:
+    def run(self, func: Callable[..., Any]) -> Union[None, Awaitable]:
         if is_coroutine(func):
         if is_coroutine(func):
             async def wait_for_result() -> None:
             async def wait_for_result() -> None:
                 with self.container:
                 with self.container:
@@ -33,6 +33,7 @@ class RefreshableTarget:
                     func(*self.args, **self.kwargs)
                     func(*self.args, **self.kwargs)
                 else:
                 else:
                     func(self.instance, *self.args, **self.kwargs)
                     func(self.instance, *self.args, **self.kwargs)
+            return None  # required by mypy
 
 
 
 
 class refreshable:
 class refreshable:
@@ -51,7 +52,7 @@ class refreshable:
         self.instance = instance
         self.instance = instance
         return self
         return self
 
 
-    def __call__(self, *args: Any, **kwargs: Any) -> None:
+    def __call__(self, *args: Any, **kwargs: Any) -> Union[None, Awaitable]:
         self.prune()
         self.prune()
         target = RefreshableTarget(container=Element('refreshable'), instance=self.instance, args=args, kwargs=kwargs)
         target = RefreshableTarget(container=Element('refreshable'), instance=self.instance, args=args, kwargs=kwargs)
         self.targets.append(target)
         self.targets.append(target)
@@ -65,6 +66,7 @@ class refreshable:
             target.container.clear()
             target.container.clear()
             result = target.run(self.func)
             result = target.run(self.func)
             if is_coroutine(self.func):
             if is_coroutine(self.func):
+                assert result is not None
                 if globals.loop and globals.loop.is_running():
                 if globals.loop and globals.loop.is_running():
                     background_tasks.create(result)
                     background_tasks.create(result)
                 else:
                 else:

+ 8 - 3
nicegui/functions/timer.py

@@ -1,10 +1,11 @@
 import asyncio
 import asyncio
 import time
 import time
-from typing import Any, Callable
+from typing import Any, Callable, Optional
 
 
 from .. import background_tasks, globals
 from .. import background_tasks, globals
 from ..binding import BindableProperty
 from ..binding import BindableProperty
 from ..helpers import is_coroutine
 from ..helpers import is_coroutine
+from ..slot import Slot
 
 
 
 
 class Timer:
 class Timer:
@@ -29,9 +30,9 @@ class Timer:
         :param once: whether the callback is only executed once after a delay specified by `interval` (default: `False`)
         :param once: whether the callback is only executed once after a delay specified by `interval` (default: `False`)
         """
         """
         self.interval = interval
         self.interval = interval
-        self.callback = callback
+        self.callback: Optional[Callable[..., Any]] = callback
         self.active = active
         self.active = active
-        self.slot = globals.get_slot()
+        self.slot: Optional[Slot] = globals.get_slot()
 
 
         coroutine = self._run_once if once else self._run_in_loop
         coroutine = self._run_once if once else self._run_in_loop
         if globals.state == globals.State.STARTED:
         if globals.state == globals.State.STARTED:
@@ -43,6 +44,7 @@ class Timer:
         try:
         try:
             if not await self._connected():
             if not await self._connected():
                 return
                 return
+            assert self.slot is not None
             with self.slot:
             with self.slot:
                 await asyncio.sleep(self.interval)
                 await asyncio.sleep(self.interval)
                 if globals.state not in {globals.State.STOPPING, globals.State.STOPPED}:
                 if globals.state not in {globals.State.STOPPING, globals.State.STOPPED}:
@@ -54,6 +56,7 @@ class Timer:
         try:
         try:
             if not await self._connected():
             if not await self._connected():
                 return
                 return
+            assert self.slot is not None
             with self.slot:
             with self.slot:
                 while True:
                 while True:
                     if self.slot.parent.client.id not in globals.clients:
                     if self.slot.parent.client.id not in globals.clients:
@@ -76,6 +79,7 @@ class Timer:
 
 
     async def _invoke_callback(self) -> None:
     async def _invoke_callback(self) -> None:
         try:
         try:
+            assert self.callback is not None
             result = self.callback()
             result = self.callback()
             if is_coroutine(self.callback):
             if is_coroutine(self.callback):
                 await result
                 await result
@@ -88,6 +92,7 @@ class Timer:
         See https://github.com/zauberzeug/nicegui/issues/206 for details.
         See https://github.com/zauberzeug/nicegui/issues/206 for details.
         Returns True if the client is connected, False if the client is not connected and the timer should be cancelled.
         Returns True if the client is connected, False if the client is not connected and the timer should be cancelled.
         """
         """
+        assert self.slot is not None
         if self.slot.parent.client.shared:
         if self.slot.parent.client.shared:
             return True
             return True
         else:
         else:

+ 2 - 2
nicegui/globals.py

@@ -3,7 +3,7 @@ import inspect
 import logging
 import logging
 from contextlib import contextmanager
 from contextlib import contextmanager
 from enum import Enum
 from enum import Enum
-from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Optional, Union
+from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterator, List, Optional, Union
 
 
 from socketio import AsyncServer
 from socketio import AsyncServer
 from uvicorn import Server
 from uvicorn import Server
@@ -86,7 +86,7 @@ def get_client() -> 'Client':
 
 
 
 
 @contextmanager
 @contextmanager
-def socket_id(id: str) -> None:
+def socket_id(id: str) -> Iterator[None]:
     global _socket_id
     global _socket_id
     _socket_id = id
     _socket_id = id
     yield
     yield

+ 122 - 1
nicegui/native.py

@@ -1,10 +1,131 @@
+import asyncio
+import inspect
+import logging
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
-from typing import Any, Dict
+from functools import partial
+from multiprocessing import Queue
+from typing import Any, Callable, Dict, Optional, Tuple
+
+import webview
+from webview.window import FixPoint
 
 
 from .helpers import KWONLY_SLOTS
 from .helpers import KWONLY_SLOTS
 
 
+method_queue = Queue()
+response_queue = Queue()
+
+
+class WindowProxy(webview.Window):
+
+    def __init__(self) -> None:
+        pass  # NOTE we don't call super().__init__ here because this is just a proxy to the actual window
+
+    async def get_always_on_top(self) -> bool:
+        """Get whether the window is always on top."""
+        return await self._request()
+
+    def set_always_on_top(self, on_top: bool) -> None:
+        """Set whether the window is always on top."""
+        self._send(on_top)
+
+    async def get_size(self) -> Tuple[int, int]:
+        """Get the window size as tuple (width, height)."""
+        return await self._request()
+
+    async def get_position(self) -> Tuple[int, int]:
+        """Get the window position as tuple (x, y)."""
+        return await self._request()
+
+    def load_url(self, url: str) -> None:
+        self._send(url)
+
+    def load_html(self, content: str, base_uri: str = ...) -> None:
+        self._send(content, base_uri)
+
+    def load_css(self, stylesheet: str) -> None:
+        self._send(stylesheet)
+
+    def set_title(self, title: str) -> None:
+        self._send(title)
+
+    async def get_cookies(self) -> Any:
+        return await self._request()
+
+    async def get_current_url(self) -> str:
+        return await self._request()
+
+    def destroy(self) -> None:
+        self._send()
+
+    def show(self) -> None:
+        self._send()
+
+    def hide(self) -> None:
+        self._send()
+
+    def set_window_size(self, width: int, height: int) -> None:
+        self._send(width, height)
+
+    def resize(self, width: int, height: int, fix_point: FixPoint = FixPoint.NORTH | FixPoint.WEST) -> None:
+        self._send(width, height, fix_point)
+
+    def minimize(self) -> None:
+        self._send()
+
+    def restore(self) -> None:
+        self._send()
+
+    def toggle_fullscreen(self) -> None:
+        self._send()
+
+    def move(self, x: int, y: int) -> None:
+        self._send(x, y)
+
+    async def evaluate_js(self, script: str) -> str:
+        return await self._request(script)
+
+    async def create_confirmation_dialog(self, title: str, message: str) -> bool:
+        return await self._request(title, message)
+
+    async def create_file_dialog(
+        self,
+        dialog_type: int = webview.OPEN_DIALOG,
+        directory: str = '',
+        allow_multiple: bool = False,
+        save_filename: str = '',
+        file_types: Tuple[str, ...] = (),
+    ) -> Tuple[str, ...]:
+        return await self._request(
+            dialog_type=dialog_type,
+            directory=directory,
+            allow_multiple=allow_multiple,
+            save_filename=save_filename,
+            file_types=file_types,
+        )
+
+    def expose(self, function: Callable) -> None:
+        raise NotImplementedError(f'exposing "{function}" is not supported')
+
+    def _send(self, *args: Any, **kwargs: Any) -> None:
+        name = inspect.currentframe().f_back.f_code.co_name
+        method_queue.put((name, args, kwargs))
+
+    async def _request(self, *args: Any, **kwargs: Any) -> Any:
+        def wrapper(*args: Any, **kwargs: Any) -> Any:
+            try:
+                method_queue.put((name, args, kwargs))
+                return response_queue.get()  # wait for the method to be called and writing its result to the queue
+            except Exception:
+                logging.exception(f'error in {name}')
+        name = inspect.currentframe().f_back.f_code.co_name
+        return await asyncio.get_event_loop().run_in_executor(None, partial(wrapper, *args, **kwargs))
+
+    def signal_server_shutdown(self) -> None:
+        self._send()
+
 
 
 @dataclass(**KWONLY_SLOTS)
 @dataclass(**KWONLY_SLOTS)
 class Native:
 class Native:
     start_args: Dict[str, Any] = field(default_factory=dict)
     start_args: Dict[str, Any] = field(default_factory=dict)
     window_args: Dict[str, Any] = field(default_factory=dict)
     window_args: Dict[str, Any] = field(default_factory=dict)
+    main_window: Optional[WindowProxy] = None

+ 63 - 9
nicegui/native_mode.py

@@ -1,13 +1,16 @@
 import _thread
 import _thread
-import multiprocessing
+import logging
+import multiprocessing as mp
+import queue
 import socket
 import socket
 import sys
 import sys
 import tempfile
 import tempfile
 import time
 import time
 import warnings
 import warnings
-from threading import Thread
+from threading import Event, Thread
+from typing import Any, Callable, Dict, List, Tuple
 
 
-from . import globals, helpers
+from . import globals, helpers, native
 
 
 try:
 try:
     with warnings.catch_warnings():
     with warnings.catch_warnings():
@@ -18,7 +21,10 @@ except ModuleNotFoundError:
     pass
     pass
 
 
 
 
-def open_window(host: str, port: int, title: str, width: int, height: int, fullscreen: bool) -> None:
+def open_window(
+    host: str, port: int, title: str, width: int, height: int, fullscreen: bool,
+    method_queue: mp.Queue, response_queue: mp.Queue,
+) -> None:
     while not helpers.is_port_open(host, port):
     while not helpers.is_port_open(host, port):
         time.sleep(0.1)
         time.sleep(0.1)
 
 
@@ -26,13 +32,60 @@ def open_window(host: str, port: int, title: str, width: int, height: int, fulls
     window_kwargs.update(globals.app.native.window_args)
     window_kwargs.update(globals.app.native.window_args)
 
 
     try:
     try:
-        webview.create_window(**window_kwargs)
+        window = webview.create_window(**window_kwargs)
+        closing = Event()
+        window.events.closing += closing.set
+        start_window_method_executor(window, method_queue, response_queue, closing)
         webview.start(storage_path=tempfile.mkdtemp(), **globals.app.native.start_args)
         webview.start(storage_path=tempfile.mkdtemp(), **globals.app.native.start_args)
     except NameError:
     except NameError:
-        print('Native mode is not supported in this configuration. Please install pywebview to use it.')
+        logging.error('Native mode is not supported in this configuration. Please install pywebview to use it.')
         sys.exit(1)
         sys.exit(1)
 
 
 
 
+def start_window_method_executor(
+        window: webview.Window, method_queue: mp.Queue, response_queue: mp.Queue, closing: Event
+) -> None:
+    def execute(method: Callable, args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> None:
+        try:
+            response = method(*args, **kwargs)
+            if response is not None or 'dialog' in method.__name__:
+                response_queue.put(response)
+        except Exception:
+            logging.exception(f'error in window.{method.__name__}')
+
+    def window_method_executor() -> None:
+        pending_executions: List[Thread] = []
+        while not closing.is_set():
+            try:
+                method_name, args, kwargs = method_queue.get(block=False)
+                if method_name == 'signal_server_shutdown':
+                    if pending_executions:
+                        logging.warning('shutdown is possibly blocked by opened dialogs like a file picker')
+                        while pending_executions:
+                            pending_executions.pop().join()
+                elif method_name == 'get_always_on_top':
+                    response_queue.put(window.on_top)
+                elif method_name == 'set_always_on_top':
+                    window.on_top = args[0]
+                elif method_name == 'get_position':
+                    response_queue.put((int(window.x), int(window.y)))
+                elif method_name == 'get_size':
+                    response_queue.put((int(window.width), int(window.height)))
+                else:
+                    method = getattr(window, method_name)
+                    if callable(method):
+                        pending_executions.append(Thread(target=execute, args=(method, args, kwargs)))
+                        pending_executions[-1].start()
+                    else:
+                        logging.error(f'window.{method_name} is not callable')
+            except queue.Empty:
+                time.sleep(0.01)
+            except Exception:
+                logging.exception(f'error in window.{method_name}')
+
+    Thread(target=window_method_executor).start()
+
+
 def activate(host: str, port: int, title: str, width: int, height: int, fullscreen: bool) -> None:
 def activate(host: str, port: int, title: str, width: int, height: int, fullscreen: bool) -> None:
     def check_shutdown() -> None:
     def check_shutdown() -> None:
         while process.is_alive():
         while process.is_alive():
@@ -42,10 +95,11 @@ def activate(host: str, port: int, title: str, width: int, height: int, fullscre
             time.sleep(0.1)
             time.sleep(0.1)
         _thread.interrupt_main()
         _thread.interrupt_main()
 
 
-    multiprocessing.freeze_support()
-    process = multiprocessing.Process(target=open_window, args=(host, port, title, width, height, fullscreen),
-                                      daemon=False)
+    mp.freeze_support()
+    args = host, port, title, width, height, fullscreen, native.method_queue, native.response_queue
+    process = mp.Process(target=open_window, args=args, daemon=False)
     process.start()
     process.start()
+
     Thread(target=check_shutdown, daemon=True).start()
     Thread(target=check_shutdown, daemon=True).start()
 
 
 
 

+ 12 - 3
nicegui/nicegui.py

@@ -15,7 +15,7 @@ from fastapi_socketio import SocketManager
 from nicegui import json
 from nicegui import json
 from nicegui.json import NiceGUIJSONResponse
 from nicegui.json import NiceGUIJSONResponse
 
 
-from . import __version__, background_tasks, binding, globals, outbox
+from . import __version__, background_tasks, binding, favicon, globals, outbox
 from .app import App
 from .app import App
 from .client import Client
 from .client import Client
 from .dependencies import js_components, js_dependencies
 from .dependencies import js_components, js_dependencies
@@ -27,7 +27,7 @@ from .page import page
 globals.app = app = App(default_response_class=NiceGUIJSONResponse)
 globals.app = app = App(default_response_class=NiceGUIJSONResponse)
 # NOTE we use custom json module which wraps orjson
 # NOTE we use custom json module which wraps orjson
 socket_manager = SocketManager(app=app, mount_location='/_nicegui_ws/', json=json)
 socket_manager = SocketManager(app=app, mount_location='/_nicegui_ws/', json=json)
-globals.sio = sio = app.sio
+globals.sio = sio = socket_manager._sio
 
 
 app.add_middleware(GZipMiddleware)
 app.add_middleware(GZipMiddleware)
 static_files = StaticFiles(
 static_files = StaticFiles(
@@ -66,6 +66,13 @@ def handle_startup(with_welcome_message: bool = True) -> None:
                            'remove the guard or replace it with\n'
                            'remove the guard or replace it with\n'
                            '   if __name__ in {"__main__", "__mp_main__"}:\n'
                            '   if __name__ in {"__main__", "__mp_main__"}:\n'
                            'to allow for multiprocessing.')
                            'to allow for multiprocessing.')
+    if globals.favicon:
+        if Path(globals.favicon).exists():
+            globals.app.add_route('/favicon.ico', lambda _: FileResponse(globals.favicon))
+        else:
+            globals.app.add_route('/favicon.ico', lambda _: favicon.get_favicon_response())
+    else:
+        globals.app.add_route('/favicon.ico', lambda _: FileResponse(Path(__file__).parent / 'static' / 'favicon.ico'))
     globals.state = globals.State.STARTING
     globals.state = globals.State.STARTING
     globals.loop = asyncio.get_running_loop()
     globals.loop = asyncio.get_running_loop()
     with globals.index_client:
     with globals.index_client:
@@ -98,7 +105,9 @@ def print_welcome_message():
 
 
 
 
 @app.on_event('shutdown')
 @app.on_event('shutdown')
-def handle_shutdown() -> None:
+async def handle_shutdown() -> None:
+    if app.native.main_window:
+        app.native.main_window.signal_server_shutdown()
     globals.state = globals.State.STOPPING
     globals.state = globals.State.STOPPING
     with globals.index_client:
     with globals.index_client:
         for t in globals.shutdown_handlers:
         for t in globals.shutdown_handlers:

+ 3 - 3
nicegui/outbox.py

@@ -7,7 +7,7 @@ from . import globals
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from .element import Element
     from .element import Element
 
 
-ClientId = int
+ClientId = str
 ElementId = int
 ElementId = int
 MessageType = str
 MessageType = str
 Message = Tuple[ClientId, MessageType, Any]
 Message = Tuple[ClientId, MessageType, Any]
@@ -32,8 +32,8 @@ async def loop() -> None:
         coros = []
         coros = []
         try:
         try:
             for client_id, elements in update_queue.items():
             for client_id, elements in update_queue.items():
-                elements = {element_id: element._to_dict() for element_id, element in elements.items()}
-                coros.append(globals.sio.emit('update', elements, room=client_id))
+                data = {element_id: element._to_dict() for element_id, element in elements.items()}
+                coros.append(globals.sio.emit('update', data, room=client_id))
             update_queue.clear()
             update_queue.clear()
             for client_id, message_type, data in message_queue:
             for client_id, message_type, data in message_queue:
                 coros.append(globals.sio.emit(message_type, data, room=client_id))
                 coros.append(globals.sio.emit(message_type, data, room=client_id))

+ 13 - 3
nicegui/page.py

@@ -1,7 +1,7 @@
 import asyncio
 import asyncio
 import inspect
 import inspect
 import time
 import time
-from typing import Any, Callable, Optional
+from typing import TYPE_CHECKING, Any, Callable, Optional
 
 
 from fastapi import Request, Response
 from fastapi import Request, Response
 
 
@@ -10,6 +10,9 @@ from .client import Client
 from .favicon import create_favicon_route
 from .favicon import create_favicon_route
 from .language import Language
 from .language import Language
 
 
+if TYPE_CHECKING:
+    from .api_router import APIRouter
+
 
 
 class page:
 class page:
 
 
@@ -21,6 +24,7 @@ class page:
                  dark: Optional[bool] = ...,
                  dark: Optional[bool] = ...,
                  language: Language = ...,
                  language: Language = ...,
                  response_timeout: float = 3.0,
                  response_timeout: float = 3.0,
+                 api_router: Optional['APIRouter'] = None,
                  **kwargs: Any,
                  **kwargs: Any,
                  ) -> None:
                  ) -> None:
         """Page
         """Page
@@ -37,9 +41,10 @@ class page:
         :param dark: whether to use Quasar's dark mode (defaults to `dark` argument of `run` command)
         :param dark: whether to use Quasar's dark mode (defaults to `dark` argument of `run` command)
         :param language: language of the page (defaults to `language` argument of `run` command)
         :param language: language of the page (defaults to `language` argument of `run` command)
         :param response_timeout: maximum time for the decorated function to build the page (default: 3.0)
         :param response_timeout: maximum time for the decorated function to build the page (default: 3.0)
+        :param api_router: APIRouter instance to use, can be left `None` to use the default
         :param kwargs: additional keyword arguments passed to FastAPI's @app.get method
         :param kwargs: additional keyword arguments passed to FastAPI's @app.get method
         """
         """
-        self.path = path
+        self._path = path
         self.title = title
         self.title = title
         self.viewport = viewport
         self.viewport = viewport
         self.favicon = favicon
         self.favicon = favicon
@@ -47,9 +52,14 @@ class page:
         self.language = language
         self.language = language
         self.response_timeout = response_timeout
         self.response_timeout = response_timeout
         self.kwargs = kwargs
         self.kwargs = kwargs
+        self.api_router = api_router or globals.app.router
 
 
         create_favicon_route(self.path, favicon)
         create_favicon_route(self.path, favicon)
 
 
+    @property
+    def path(self) -> str:
+        return self.api_router.prefix + self._path
+
     def resolve_title(self) -> str:
     def resolve_title(self) -> str:
         return self.title if self.title is not None else globals.title
         return self.title if self.title is not None else globals.title
 
 
@@ -96,6 +106,6 @@ class page:
             parameters.insert(0, request)
             parameters.insert(0, request)
         decorated.__signature__ = inspect.Signature(parameters)
         decorated.__signature__ = inspect.Signature(parameters)
 
 
-        globals.app.get(self.path, **self.kwargs)(decorated)
+        self.api_router.get(self._path, **self.kwargs)(decorated)
         globals.page_routes[func] = self.path
         globals.page_routes[func] = self.path
         return func
         return func

+ 17 - 1
nicegui/run.py

@@ -1,6 +1,7 @@
 import logging
 import logging
 import multiprocessing
 import multiprocessing
 import os
 import os
+import socket
 import sys
 import sys
 from typing import Any, List, Optional, Tuple
 from typing import Any, List, Optional, Tuple
 
 
@@ -10,7 +11,9 @@ from starlette.middleware.sessions import SessionMiddleware
 from uvicorn.main import STARTUP_FAILURE
 from uvicorn.main import STARTUP_FAILURE
 from uvicorn.supervisors import ChangeReload, Multiprocess
 from uvicorn.supervisors import ChangeReload, Multiprocess
 
 
-from . import globals, helpers, native_mode
+from . import globals, helpers
+from . import native as native_module
+from . import native_mode
 from .language import Language
 from .language import Language
 from .storage import RequestTrackingMiddleware
 from .storage import RequestTrackingMiddleware
 
 
@@ -24,6 +27,17 @@ class Server(uvicorn.Server):
         super().run(sockets=sockets)
         super().run(sockets=sockets)
 
 
 
 
+class Server(uvicorn.Server):
+
+    def run(self, sockets: Optional[List[socket.socket]] = None) -> None:
+        globals.server = self
+        native_module.method_queue = self.config.method_queue
+        native_module.response_queue = self.config.response_queue
+        if native_module.method_queue is not None:
+            globals.app.native.main_window = native_module.WindowProxy()
+        super().run(sockets=sockets)
+
+
 def run(*,
 def run(*,
         host: Optional[str] = None,
         host: Optional[str] = None,
         port: int = 8080,
         port: int = 8080,
@@ -129,6 +143,8 @@ def run(*,
         **kwargs,
         **kwargs,
     )
     )
     config.storage_secret = storage_secret
     config.storage_secret = storage_secret
+    config.method_queue = native_module.method_queue if native else None
+    config.response_queue = native_module.response_queue if native else None
     globals.server = Server(config=config)
     globals.server = Server(config=config)
 
 
     if (reload or config.workers > 1) and not isinstance(config.app, str):
     if (reload or config.workers > 1) and not isinstance(config.app, str):

+ 5 - 3
nicegui/tailwind.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
-from typing import TYPE_CHECKING, List, Optional, overload
+from typing import TYPE_CHECKING, List, Optional, Union, overload
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from .element import Element
     from .element import Element
@@ -177,10 +177,10 @@ class PseudoElement:
 class Tailwind:
 class Tailwind:
 
 
     def __init__(self, _element: Optional['Element'] = None) -> None:
     def __init__(self, _element: Optional['Element'] = None) -> None:
-        self.element = PseudoElement() if _element is None else _element
+        self.element: Union[PseudoElement, Element] = PseudoElement() if _element is None else _element
 
 
     @overload
     @overload
-    def __call__(self, Tailwind) -> Tailwind:
+    def __call__(self, tailwind: Tailwind) -> Tailwind:
         ...
         ...
 
 
     @overload
     @overload
@@ -188,6 +188,8 @@ class Tailwind:
         ...
         ...
 
 
     def __call__(self, *args) -> Tailwind:
     def __call__(self, *args) -> Tailwind:
+        if not args:
+            return self
         if isinstance(args[0], Tailwind):
         if isinstance(args[0], Tailwind):
             args[0].apply(self.element)
             args[0].apply(self.element)
         else:
         else:

+ 2 - 0
tests/conftest.py

@@ -40,6 +40,8 @@ def reset_globals() -> Generator[None, None, None]:
     for path in {'/'}.union(globals.page_routes.values()):
     for path in {'/'}.union(globals.page_routes.values()):
         globals.app.remove_route(path)
         globals.app.remove_route(path)
     globals.app.middleware_stack = None
     globals.app.middleware_stack = None
+    # NOTE favicon routes must be removed separately because they are not "pages"
+    [globals.app.routes.remove(r) for r in globals.app.routes if r.path.endswith('/favicon.ico')]
     importlib.reload(globals)
     importlib.reload(globals)
     # importlib.reload(nicegui)
     # importlib.reload(nicegui)
     globals.app.storage.general.clear()
     globals.app.storage.general.clear()

+ 2 - 1
tests/requirements.txt

@@ -3,4 +3,5 @@ pytest-selenium
 pytest-asyncio
 pytest-asyncio
 selenium
 selenium
 autopep8
 autopep8
-icecream
+icecream
+beautifulsoup4

+ 31 - 0
tests/test_api_router.py

@@ -0,0 +1,31 @@
+
+from nicegui import APIRouter, app, ui
+
+from .screen import Screen
+
+
+def test_prefix(screen: Screen):
+    router = APIRouter(prefix='/some-prefix')
+
+    @router.page('/')
+    def page():
+        ui.label('Hello, world!')
+
+    app.include_router(router)
+
+    screen.open('/some-prefix')
+    screen.should_contain('NiceGUI')
+    screen.should_contain('Hello, world!')
+
+
+def test_passing_page_parameters(screen: Screen):
+    router = APIRouter()
+
+    @router.page('/', title='My Custom Title')
+    def page():
+        ui.label('Hello, world!')
+
+    app.include_router(router)
+
+    screen.open('/')
+    screen.should_contain('My Custom Title')

+ 92 - 0
tests/test_favicon.py

@@ -0,0 +1,92 @@
+from pathlib import Path
+from typing import Union
+
+import requests
+from bs4 import BeautifulSoup
+
+from nicegui import favicon, ui
+
+from .screen import PORT, Screen
+
+DEFAULT_FAVICON_PATH = Path(__file__).parent.parent / 'nicegui' / 'static' / 'favicon.ico'
+LOGO_FAVICON_PATH = Path(__file__).parent.parent / 'website' / 'static' / 'logo_square.png'
+
+
+def assert_favicon_url_starts_with(screen: Screen, content: str):
+    soup = BeautifulSoup(screen.selenium.page_source, 'html.parser')
+    icon_link = soup.find("link", rel="icon")
+    assert icon_link['href'].startswith(content)
+
+
+def assert_favicon(content: Union[Path, str, bytes], url_path: str = '/favicon.ico'):
+    response = requests.get(f'http://localhost:{PORT}{url_path}')
+    assert response.status_code == 200
+    if isinstance(content, Path):
+        assert content.read_bytes() == response.content
+    elif isinstance(content, str):
+        assert content == response.text
+    elif isinstance(content, bytes):
+        assert content == response.content
+    else:
+        raise TypeError(f'Unexpected type: {type(content)}')
+
+
+def test_default(screen: Screen):
+    ui.label('Hello, world')
+
+    screen.open('/')
+    assert_favicon(DEFAULT_FAVICON_PATH)
+
+
+def test_emoji(screen: Screen):
+    ui.label('Hello, world')
+
+    screen.ui_run_kwargs['favicon'] = '👋'
+    screen.open('/')
+    assert_favicon_url_starts_with(screen, 'data:image/svg+xml')
+    assert_favicon(favicon.char_to_svg('👋'))
+
+
+def test_data_url(screen: Screen):
+    ui.label('Hello, world')
+
+    icon = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=='
+    screen.ui_run_kwargs['favicon'] = icon
+    screen.open('/')
+    assert_favicon_url_starts_with(screen, 'data:image/png;base64')
+    _, bytes = favicon.data_url_to_bytes(icon)
+    assert_favicon(bytes)
+
+
+def test_custom_file(screen: Screen):
+    ui.label('Hello, world')
+
+    screen.ui_run_kwargs['favicon'] = LOGO_FAVICON_PATH
+    screen.open('/')
+    assert_favicon_url_starts_with(screen, '/favicon.ico')
+    assert_favicon(screen.ui_run_kwargs['favicon'])
+
+
+def test_page_specific_icon(screen: Screen):
+    @ui.page('/subpage', favicon=LOGO_FAVICON_PATH)
+    def sub():
+        ui.label('Subpage')
+
+    ui.label('Main')
+
+    screen.open('/subpage')
+    assert_favicon(LOGO_FAVICON_PATH, url_path='/subpage/favicon.ico')
+    screen.open('/')
+
+
+def test_page_specific_emoji(screen: Screen):
+    @ui.page('/subpage', favicon='👋')
+    def sub():
+        ui.label('Subpage')
+
+    ui.label('Main')
+
+    screen.open('/subpage')
+    assert_favicon_url_starts_with(screen, 'data:image/svg+xml')
+    screen.open('/')
+    assert_favicon(DEFAULT_FAVICON_PATH)

+ 18 - 0
tests/test_link.py

@@ -60,3 +60,21 @@ def test_updating_href_prop(screen: Screen):
     assert screen.find('nicegui.io').get_attribute('href') == 'https://nicegui.io/'
     assert screen.find('nicegui.io').get_attribute('href') == 'https://nicegui.io/'
     screen.click('change href')
     screen.click('change href')
     assert screen.find('nicegui.io').get_attribute('href') == 'https://github.com/zauberzeug/nicegui'
     assert screen.find('nicegui.io').get_attribute('href') == 'https://github.com/zauberzeug/nicegui'
+
+
+def test_link_to_elements(screen: Screen):
+    navigation = ui.row()
+    for i in range(100):
+        ui.label(f'label {i}')
+    link = ui.link('goto top', navigation)
+    with navigation:
+        ui.link('goto bottom', link)
+
+    screen.open('/')
+    assert screen.selenium.execute_script('return window.scrollY') == 0
+    screen.click('goto bottom')
+    screen.wait(0.5)
+    assert screen.selenium.execute_script('return window.scrollY') > 100
+    screen.click('goto top')
+    screen.wait(0.5)
+    assert screen.selenium.execute_script('return window.scrollY') < 100

+ 9 - 3
website/demo.py

@@ -1,4 +1,5 @@
 import inspect
 import inspect
+import re
 from typing import Callable, Optional, Union
 from typing import Callable, Optional, Union
 
 
 import isort
 import isort
@@ -15,13 +16,18 @@ BROWSER_BGCOLOR = '#00000010'
 BROWSER_COLOR = '#ffffff'
 BROWSER_COLOR = '#ffffff'
 
 
 
 
-def remove_prefix(text: str, prefix: str) -> str:
-    return text[len(prefix):] if text.startswith(prefix) else text
+uncomment_pattern = re.compile(r'^(\s*)# ?')
+
+
+def uncomment(text: str) -> str:
+    """non-executed lines should be shown in the code examples"""
+    return uncomment_pattern.sub(r'\1', text)
 
 
 
 
 def demo(f: Callable) -> Callable:
 def demo(f: Callable) -> Callable:
     with ui.column().classes('w-full items-stretch gap-8 no-wrap min-[1500px]:flex-row'):
     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()
         code = inspect.getsource(f).split('# END OF DEMO')[0].strip().splitlines()
+        code = [line for line in code if not line.endswith("# HIDE")]
         while not code[0].strip().startswith('def') and not code[0].strip().startswith('async def'):
         while not code[0].strip().startswith('def') and not code[0].strip().startswith('async def'):
             del code[0]
             del code[0]
         del code[0]
         del code[0]
@@ -31,7 +37,7 @@ def demo(f: Callable) -> Callable:
             del code[0]
             del code[0]
         indentation = len(code[0]) - len(code[0].lstrip())
         indentation = len(code[0]) - len(code[0].lstrip())
         code = [line[indentation:] for line in code]
         code = [line[indentation:] for line in code]
-        code = ['from nicegui import ui'] + [remove_prefix(line, '# ') for line in code]
+        code = ['from nicegui import ui'] + [uncomment(line) for line in code]
         code = ['' if line == '#' else line for line in code]
         code = ['' if line == '#' else line for line in code]
         if not code[-1].startswith('ui.run('):
         if not code[-1].startswith('ui.run('):
             code.append('')
             code.append('')

+ 13 - 7
website/documentation.py

@@ -583,22 +583,28 @@ def create_full() -> None:
     demo.BROWSER_BGCOLOR = '#ffffff'
     demo.BROWSER_BGCOLOR = '#ffffff'
 
 
     @text_demo('Native Mode', '''
     @text_demo('Native Mode', '''
-        You can enable native mode for NiceGUI by specifying `native=True` in the `ui.run` function. 
-        To customize the initial window size and display mode, use the `window_size` and `fullscreen` parameters respectively. 
+        You can enable native mode for NiceGUI by specifying `native=True` in the `ui.run` function.
+        To customize the initial window size and display mode, use the `window_size` and `fullscreen` parameters respectively.
         Additionally, you can provide extra keyword arguments via `app.native.window_args` and `app.native.start_args`.
         Additionally, you can provide extra keyword arguments via `app.native.window_args` and `app.native.start_args`.
-        Pick any parameter as it is defined by the internally used [pywebview module](https://pywebview.flowrl.com/guide/api.html) 
+        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.
         for the `webview.create_window` and `webview.start` functions.
-        Note that these keyword arguments will take precedence over the parameters defined in ui.run.
+        Note that these keyword arguments will take precedence over the parameters defined in `ui.run`.
+
+        In native mode the `app.native.main_window` object allows you to access the underlying window.
+        It is an async version of [`Window` from pywebview](https://pywebview.flowrl.com/guide/api.html#window-object).
     ''', tab=lambda: ui.label('NiceGUI'))
     ''', tab=lambda: ui.label('NiceGUI'))
     def native_mode_demo():
     def native_mode_demo():
         from nicegui import app
         from nicegui import app
 
 
-        ui.label('app running in native mode')
-
         app.native.window_args['resizable'] = False
         app.native.window_args['resizable'] = False
         app.native.start_args['debug'] = True
         app.native.start_args['debug'] = True
 
 
+        ui.label('app running in native mode')
+        # ui.button('enlarge', on_click=lambda: app.native.main_window.resize(1000, 700))
+        #
         # ui.run(native=True, window_size=(400, 300), fullscreen=False)
         # ui.run(native=True, window_size=(400, 300), fullscreen=False)
+        # END OF DEMO
+        ui.button('enlarge', on_click=lambda: ui.notify('window will be set to 1000x700 in native mode'))
     # HACK: restore color
     # HACK: restore color
     demo.BROWSER_BGCOLOR = demo_BROWSER_BGCOLOR
     demo.BROWSER_BGCOLOR = demo_BROWSER_BGCOLOR
 
 
@@ -707,7 +713,7 @@ def create_full() -> None:
                     '--name', 'myapp', # name of your app
                     '--name', 'myapp', # name of your app
                     '--onefile',
                     '--onefile',
                     #'--windowed', # prevent console appearing, only use with ui.run(native=True, ...)
                     #'--windowed', # prevent console appearing, only use with ui.run(native=True, ...)
-                    '--add-data', f'{Path(nicegui.__file__).parent}{os.pathsep}nicegui'       
+                    '--add-data', f'{Path(nicegui.__file__).parent}{os.pathsep}nicegui'
                 ]
                 ]
                 subprocess.call(cmd)
                 subprocess.call(cmd)
                 ```
                 ```

+ 40 - 0
website/more_documentation/link_documentation.py

@@ -1,5 +1,45 @@
 from nicegui import ui
 from nicegui import ui
 
 
+from ..documentation_tools import text_demo
+from ..style import link_target
+
 
 
 def main_demo() -> None:
 def main_demo() -> None:
     ui.link('NiceGUI on GitHub', 'https://github.com/zauberzeug/nicegui')
     ui.link('NiceGUI on GitHub', 'https://github.com/zauberzeug/nicegui')
+
+
+def more() -> None:
+    @text_demo('Navigate on large pages', '''
+        To jump to a specific location within a page you can place linkable anchors with `ui.link_target('target_name')`
+        or simply pass a NiceGUI element as link target.
+    ''')
+    def same_page_links():
+        navigation = ui.row()
+        # ui.link_target('target_A')
+        link_target('target_A', '-10px')  # HIDE
+        ui.label(
+            'Lorem ipsum dolor sit amet, consectetur adipiscing elit, '
+            'sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
+        )
+        link_target('target_B', '70px')  # HIDE
+        label_B = ui.label(
+            'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. '
+            'Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. '
+            'Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.'
+        )
+        with navigation:
+            ui.link('Goto A', '#target_A')
+            # ui.link('Goto B', label_B)
+            ui.link('Goto B', '#target_B')  # HIDE
+
+    @text_demo('Links to other pages', '''
+        You can link to other pages by providing the link target as path or function reference.
+    ''')
+    def link_to_other_page():
+        @ui.page('/some_other_page')
+        def my_page():
+            ui.label('This is another page')
+
+        ui.label('Go to other page')
+        ui.link('... with path', '/some_other_page')
+        ui.link('... with function reference', my_page)

+ 30 - 0
website/more_documentation/page_documentation.py

@@ -54,3 +54,33 @@ def more() -> None:
             ui.label(f'The IP address {client.ip} was obtained from the websocket.')
             ui.label(f'The IP address {client.ip} was obtained from the websocket.')
 
 
         ui.link('wait for connection', wait_for_connection)
         ui.link('wait for connection', wait_for_connection)
+
+    @text_demo('Modularize with APIRouter', '''
+        You can use the NiceGUI specialization of
+        [FastAPI's APIRouter](https://fastapi.tiangolo.com/tutorial/bigger-applications/?h=apirouter#apirouter)
+        to modularize your code by grouping pages and other routes together.
+        This is especially useful if you want to reuse the same prefix for multiple pages.
+        The router and its pages can be neatly tugged away in a separate module (e.g. file) and
+        the router is simply imported and included in the main app.
+        See our [modularization example](https://github.com/zauberzeug/nicegui/blob/main/examples/modularization/example_c.py)
+        for a multi-file app structure.
+    ''', tab='/sub-path')
+    def api_router_demo():
+        # from nicegui import APIRouter, app
+        #
+        # router = APIRouter(prefix='/sub-path')
+        #
+        # @router.page('/')
+        # def page():
+        #     ui.label('This is content on /sub-path')
+        #
+        # @router.page('/sub-sub-path')
+        # def page():
+        #     ui.label('This is content on /sub-path/sub-sub-path')
+        #
+        # ui.link('Visit sub-path', '/sub-path')
+        # ui.link('Visit sub-sub-path', '/sub-path/sub-sub-path')
+        #
+        # app.include_router(router)
+        # END OF DEMO
+        ui.label('Shows up on /sub-path')