Bladeren bron

Merge commit '1aac7c09f1d7c68d0c34f8e53c3a206ec89d29f8' into session_data

Rodja Trappe 2 jaren geleden
bovenliggende
commit
ac485ed359
47 gewijzigde bestanden met toevoegingen van 747 en 141 verwijderingen
  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
           # install packages to run the examples
           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
           pip install importlib-resources
       - 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:
 
     @ui.page('/a')
-    def example_page():
+    def example_page_a():
         with theme.frame('- Example A -'):
             ui.label('Example A').classes('text-h4 text-grey-8')
 
     @ui.page('/b')
-    def example_page():
+    def example_page_b():
         with theme.frame('- Example B -'):
             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
+import example_c
 import example_pages
 import home_page
 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
@@ -16,4 +17,7 @@ def index_page() -> None:
 # this call shows that you can also move the whole page creation into a separate file
 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')

+ 1 - 1
examples/modularization/theme.py

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

+ 39 - 37
examples/todo_list/main.py

@@ -1,6 +1,6 @@
 #!/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
 
@@ -8,58 +8,60 @@ from nicegui import ui
 @dataclass
 class TodoItem:
     name: str
+    on_change: Callable
     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
-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
-    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'):
-        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'):
-            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.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'):
-    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.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()

+ 5 - 3
fetch_tailwind.py

@@ -94,7 +94,7 @@ for property in properties:
 with open(Path(__file__).parent / 'nicegui' / 'tailwind.py', 'w') as f:
     f.write('from __future__ import annotations\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('if TYPE_CHECKING:\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('\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('    @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('    @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('    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('            args[0].apply(self.element)\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('SQLite Database', 'CRUD operations on a SQLite database')
             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'):
         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:
     import importlib_metadata
 
-__version__ = importlib_metadata.version('nicegui')
+__version__: str = importlib_metadata.version('nicegui')
 
 from . import elements, globals, ui
+from .api_router import APIRouter
 from .client import Client
 from .nicegui import app
 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)
 
-    def on_exception(self, handler: Union[Callable, Awaitable]) -> None:
+    def on_exception(self, handler: Callable) -> None:
         """Called when an exception occurs.
 
         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.
     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)
     running_tasks.add(task)
     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)
 
 
-async def loop():
+async def loop() -> None:
     while True:
         visited: Set[Tuple[int, str]] = set()
         t = time.time()

+ 2 - 2
nicegui/client.py

@@ -40,7 +40,7 @@ class Client:
                 with Element('q-page'):
                     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.body_html = ''
@@ -108,7 +108,7 @@ class Client:
         self.is_waiting_for_disconnect = False
 
     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.
 
         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:
                 yield child
 
-    def _collect_slot_dict(self) -> Dict[str, List[int]]:
+    def _collect_slot_dict(self) -> Dict[str, Any]:
         return {
             name: {'template': slot.template, 'ids': [child.id for child in slot]}
             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_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.parent.update()
         target_container = target_container or self.parent_slot.parent

+ 3 - 2
nicegui/elements/aggrid.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from typing import Dict, List, Optional
+from typing import Dict, List, Optional, cast
 
 from ..dependencies import register_component
 from ..element import Element
@@ -67,7 +67,8 @@ class AgGrid(Element):
 
         :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]:
         """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]:
                 if self.value:
                     needle = str(self.value).casefold()
-                    for item in autocomplete:
+                    for item in autocomplete or []:
                         if item.casefold().startswith(needle):
                             return item
+                return None  # required by mypy
 
             def autocomplete_input() -> None:
                 match = find_autocompletion() or ''

+ 12 - 3
nicegui/elements/link.py

@@ -10,7 +10,11 @@ register_component('link', __file__, 'link.js')
 
 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
 
         Create a hyperlink.
@@ -19,11 +23,16 @@ class Link(TextElement):
         and link to it with `ui.link(target="#name")`.
 
         :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)
         """
         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._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
 
@@ -8,9 +8,9 @@ from ...events import ValueChangeEventArguments, handle_event
 
 
 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))
 
     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
 
@@ -79,11 +79,12 @@ class Visibility:
         """
         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.
 
         :param visible: Whether the element should be visible.
         """
+        self = cast('Element', self)
         if visible and 'hidden' in self._classes:
             self._classes.remove('hidden')
             self.update()

+ 4 - 4
nicegui/elements/scene_object3d.py

@@ -1,12 +1,12 @@
 import uuid
-from typing import TYPE_CHECKING, Any, List, Optional
+from typing import TYPE_CHECKING, Any, List, Optional, Union, cast
 
 import numpy as np
 
 from .. import globals
 
 if TYPE_CHECKING:
-    from .scene import Scene
+    from .scene import Scene, SceneObject
 
 
 class Object3D:
@@ -15,9 +15,9 @@ class Object3D:
         self.type = type
         self.id = str(uuid.uuid4())
         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.parent: Object3D = self.scene.stack[-1]
+        self.parent: Union[Object3D, SceneObject] = self.scene.stack[-1]
         self.args: List = list(args)
         self.color: str = '#ffffff'
         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 ..events import EventArguments, UploadEventArguments, handle_event
@@ -51,14 +52,15 @@ class Upload(DisableableElement):
             self._props['max-files'] = max_files
 
         @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():
+                assert isinstance(data, UploadFile)
                 args = UploadEventArguments(
                     sender=self,
                     client=self.client,
                     content=data.file,
-                    name=data.filename,
-                    type=data.content_type,
+                    name=data.filename or '',
+                    type=data.content_type or '',
                 )
                 handle_event(on_upload, args)
             return {'upload': 'success'}

+ 2 - 2
nicegui/event_listener.py

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

+ 2 - 2
nicegui/events.py

@@ -1,5 +1,5 @@
 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 . import background_tasks, globals
@@ -274,7 +274,7 @@ def handle_event(handler: Optional[Callable[..., Any]],
     try:
         if handler is None:
             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
         assert sender is not None and sender.parent_slot is not None
         with sender.parent_slot:

+ 31 - 15
nicegui/favicon.py

@@ -1,8 +1,10 @@
+import base64
+import io
 import urllib.parse
 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
 
@@ -11,32 +13,41 @@ if TYPE_CHECKING:
 
 
 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:
     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'
+    favicon = str(favicon)
+    if is_remote_url(favicon):
+        return 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)
+        return svg_to_data_url(char_to_svg(favicon))
     elif page.path == '/':
         return f'{prefix}/favicon.ico'
     else:
         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:
     return favicon.startswith('http://') or favicon.startswith('https://')
 
@@ -53,8 +64,8 @@ def is_data_url(favicon: str) -> bool:
     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" >
             <style>
                 @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>
         </svg>
     '''
-    return svg_to_data_url(svg)
 
 
 def svg_to_data_url(svg: str) -> str:
     svg_urlencoded = urllib.parse.quote(svg)
     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
 
 
 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
 
     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 typing import Any, Callable, Dict, List
+from typing import Any, Awaitable, Callable, Dict, List, Tuple, Union
 
 from typing_extensions import Self
 
@@ -15,10 +15,10 @@ register_component('refreshable', __file__, 'refreshable.js')
 class RefreshableTarget:
     container: Element
     instance: Any
-    args: List[Any]
+    args: Tuple[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):
             async def wait_for_result() -> None:
                 with self.container:
@@ -33,6 +33,7 @@ class RefreshableTarget:
                     func(*self.args, **self.kwargs)
                 else:
                     func(self.instance, *self.args, **self.kwargs)
+            return None  # required by mypy
 
 
 class refreshable:
@@ -51,7 +52,7 @@ class refreshable:
         self.instance = instance
         return self
 
-    def __call__(self, *args: Any, **kwargs: Any) -> None:
+    def __call__(self, *args: Any, **kwargs: Any) -> Union[None, Awaitable]:
         self.prune()
         target = RefreshableTarget(container=Element('refreshable'), instance=self.instance, args=args, kwargs=kwargs)
         self.targets.append(target)
@@ -65,6 +66,7 @@ class refreshable:
             target.container.clear()
             result = target.run(self.func)
             if is_coroutine(self.func):
+                assert result is not None
                 if globals.loop and globals.loop.is_running():
                     background_tasks.create(result)
                 else:

+ 8 - 3
nicegui/functions/timer.py

@@ -1,10 +1,11 @@
 import asyncio
 import time
-from typing import Any, Callable
+from typing import Any, Callable, Optional
 
 from .. import background_tasks, globals
 from ..binding import BindableProperty
 from ..helpers import is_coroutine
+from ..slot import Slot
 
 
 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`)
         """
         self.interval = interval
-        self.callback = callback
+        self.callback: Optional[Callable[..., Any]] = callback
         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
         if globals.state == globals.State.STARTED:
@@ -43,6 +44,7 @@ class Timer:
         try:
             if not await self._connected():
                 return
+            assert self.slot is not None
             with self.slot:
                 await asyncio.sleep(self.interval)
                 if globals.state not in {globals.State.STOPPING, globals.State.STOPPED}:
@@ -54,6 +56,7 @@ class Timer:
         try:
             if not await self._connected():
                 return
+            assert self.slot is not None
             with self.slot:
                 while True:
                     if self.slot.parent.client.id not in globals.clients:
@@ -76,6 +79,7 @@ class Timer:
 
     async def _invoke_callback(self) -> None:
         try:
+            assert self.callback is not None
             result = self.callback()
             if is_coroutine(self.callback):
                 await result
@@ -88,6 +92,7 @@ class Timer:
         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.
         """
+        assert self.slot is not None
         if self.slot.parent.client.shared:
             return True
         else:

+ 2 - 2
nicegui/globals.py

@@ -3,7 +3,7 @@ import inspect
 import logging
 from contextlib import contextmanager
 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 uvicorn import Server
@@ -86,7 +86,7 @@ def get_client() -> 'Client':
 
 
 @contextmanager
-def socket_id(id: str) -> None:
+def socket_id(id: str) -> Iterator[None]:
     global _socket_id
     _socket_id = id
     yield

+ 122 - 1
nicegui/native.py

@@ -1,10 +1,131 @@
+import asyncio
+import inspect
+import logging
 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
 
+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)
 class Native:
     start_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 multiprocessing
+import logging
+import multiprocessing as mp
+import queue
 import socket
 import sys
 import tempfile
 import time
 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:
     with warnings.catch_warnings():
@@ -18,7 +21,10 @@ except ModuleNotFoundError:
     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):
         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)
 
     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)
     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)
 
 
+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 check_shutdown() -> None:
         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)
         _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()
+
     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.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 .client import Client
 from .dependencies import js_components, js_dependencies
@@ -27,7 +27,7 @@ from .page import page
 globals.app = app = App(default_response_class=NiceGUIJSONResponse)
 # NOTE we use custom json module which wraps orjson
 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)
 static_files = StaticFiles(
@@ -66,6 +66,13 @@ def handle_startup(with_welcome_message: bool = True) -> None:
                            'remove the guard or replace it with\n'
                            '   if __name__ in {"__main__", "__mp_main__"}:\n'
                            '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.loop = asyncio.get_running_loop()
     with globals.index_client:
@@ -98,7 +105,9 @@ def print_welcome_message():
 
 
 @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
     with globals.index_client:
         for t in globals.shutdown_handlers:

+ 3 - 3
nicegui/outbox.py

@@ -7,7 +7,7 @@ from . import globals
 if TYPE_CHECKING:
     from .element import Element
 
-ClientId = int
+ClientId = str
 ElementId = int
 MessageType = str
 Message = Tuple[ClientId, MessageType, Any]
@@ -32,8 +32,8 @@ async def loop() -> None:
         coros = []
         try:
             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()
             for client_id, message_type, data in message_queue:
                 coros.append(globals.sio.emit(message_type, data, room=client_id))

+ 13 - 3
nicegui/page.py

@@ -1,7 +1,7 @@
 import asyncio
 import inspect
 import time
-from typing import Any, Callable, Optional
+from typing import TYPE_CHECKING, Any, Callable, Optional
 
 from fastapi import Request, Response
 
@@ -10,6 +10,9 @@ from .client import Client
 from .favicon import create_favicon_route
 from .language import Language
 
+if TYPE_CHECKING:
+    from .api_router import APIRouter
+
 
 class page:
 
@@ -21,6 +24,7 @@ class page:
                  dark: Optional[bool] = ...,
                  language: Language = ...,
                  response_timeout: float = 3.0,
+                 api_router: Optional['APIRouter'] = None,
                  **kwargs: Any,
                  ) -> None:
         """Page
@@ -37,9 +41,10 @@ class page:
         :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 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
         """
-        self.path = path
+        self._path = path
         self.title = title
         self.viewport = viewport
         self.favicon = favicon
@@ -47,9 +52,14 @@ class page:
         self.language = language
         self.response_timeout = response_timeout
         self.kwargs = kwargs
+        self.api_router = api_router or globals.app.router
 
         create_favicon_route(self.path, favicon)
 
+    @property
+    def path(self) -> str:
+        return self.api_router.prefix + self._path
+
     def resolve_title(self) -> str:
         return self.title if self.title is not None else globals.title
 
@@ -96,6 +106,6 @@ class page:
             parameters.insert(0, request)
         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
         return func

+ 17 - 1
nicegui/run.py

@@ -1,6 +1,7 @@
 import logging
 import multiprocessing
 import os
+import socket
 import sys
 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.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 .storage import RequestTrackingMiddleware
 
@@ -24,6 +27,17 @@ class Server(uvicorn.Server):
         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(*,
         host: Optional[str] = None,
         port: int = 8080,
@@ -129,6 +143,8 @@ def run(*,
         **kwargs,
     )
     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)
 
     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 typing import TYPE_CHECKING, List, Optional, overload
+from typing import TYPE_CHECKING, List, Optional, Union, overload
 
 if TYPE_CHECKING:
     from .element import Element
@@ -177,10 +177,10 @@ class PseudoElement:
 class Tailwind:
 
     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
-    def __call__(self, Tailwind) -> Tailwind:
+    def __call__(self, tailwind: Tailwind) -> Tailwind:
         ...
 
     @overload
@@ -188,6 +188,8 @@ class Tailwind:
         ...
 
     def __call__(self, *args) -> Tailwind:
+        if not args:
+            return self
         if isinstance(args[0], Tailwind):
             args[0].apply(self.element)
         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()):
         globals.app.remove_route(path)
     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(nicegui)
     globals.app.storage.general.clear()

+ 2 - 1
tests/requirements.txt

@@ -3,4 +3,5 @@ pytest-selenium
 pytest-asyncio
 selenium
 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/'
     screen.click('change href')
     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 re
 from typing import Callable, Optional, Union
 
 import isort
@@ -15,13 +16,18 @@ BROWSER_BGCOLOR = '#00000010'
 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:
     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 = [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'):
             del code[0]
         del code[0]
@@ -31,7 +37,7 @@ def demo(f: Callable) -> Callable:
             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 = ['from nicegui import ui'] + [uncomment(line) for line in code]
         code = ['' if line == '#' else line for line in code]
         if not code[-1].startswith('ui.run('):
             code.append('')

+ 13 - 7
website/documentation.py

@@ -583,22 +583,28 @@ def create_full() -> None:
     demo.BROWSER_BGCOLOR = '#ffffff'
 
     @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`.
-        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.
-        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'))
     def native_mode_demo():
         from nicegui import app
 
-        ui.label('app running in native mode')
-
         app.native.window_args['resizable'] = False
         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)
+        # END OF DEMO
+        ui.button('enlarge', on_click=lambda: ui.notify('window will be set to 1000x700 in native mode'))
     # HACK: restore color
     demo.BROWSER_BGCOLOR = demo_BROWSER_BGCOLOR
 
@@ -707,7 +713,7 @@ def create_full() -> None:
                     '--name', 'myapp', # name of your app
                     '--onefile',
                     #'--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)
                 ```

+ 40 - 0
website/more_documentation/link_documentation.py

@@ -1,5 +1,45 @@
 from nicegui import ui
 
+from ..documentation_tools import text_demo
+from ..style import link_target
+
 
 def main_demo() -> None:
     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.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')