浏览代码

Merge branch 'main' into add_media_files

# Conflicts:
#	nicegui/helpers.py
Falko Schindler 1 年之前
父节点
当前提交
0e1711c086

+ 1 - 1
examples/fastapi/frontend.py

@@ -14,5 +14,5 @@ def init(fastapi_app: FastAPI) -> None:
 
     ui.run_with(
         fastapi_app,
-        storage_secret='pick your private secret here'  # NOTE setting a secret is optional but allows for persistent storage per user
+        storage_secret='pick your private secret here',  # NOTE setting a secret is optional but allows for persistent storage per user
     )

+ 8 - 4
main.py

@@ -23,6 +23,7 @@ from nicegui import ui
 from website import documentation, example_card, svg
 from website.demo import bash_window, browser_window, python_window
 from website.documentation_tools import create_anchor_name, element_demo, generate_class_doc
+from website.search import Search
 from website.star import add_star
 from website.style import example_link, features, heading, link_target, section_heading, side_menu, subtitle, title
 
@@ -33,6 +34,7 @@ app.add_middleware(SessionMiddleware, secret_key=os.environ.get('NICEGUI_SECRET_
 
 app.add_static_files('/favicon', str(Path(__file__).parent / 'website' / 'favicon'))
 app.add_static_files('/fonts', str(Path(__file__).parent / 'website' / 'fonts'))
+app.add_static_files('/static', str(Path(__file__).parent / 'website' / 'static'))
 
 
 @app.get('/logo.png')
@@ -78,18 +80,20 @@ def add_header(menu: Optional[ui.left_drawer] = None) -> None:
         if menu:
             ui.button(on_click=menu.toggle).props('flat color=white icon=menu round').classes('lg:hidden')
         with ui.link(target=index_page).classes('row gap-4 items-center no-wrap mr-auto'):
-            svg.face().classes('w-8 stroke-white stroke-2')
+            svg.face().classes('w-8 stroke-white stroke-2 max-[550px]:hidden')
             svg.word().classes('w-24')
         with ui.row().classes('max-lg:hidden'):
             for title, target in menu_items.items():
                 ui.link(title, target).classes(replace='text-lg text-white')
-        with ui.link(target='https://discord.gg/TEpFeAaF4f').classes('max-[435px]:hidden').tooltip('Discord'):
+        search = Search()
+        search.create_button()
+        with ui.link(target='https://discord.gg/TEpFeAaF4f').classes('max-[445px]:hidden').tooltip('Discord'):
             svg.discord().classes('fill-white scale-125 m-1')
-        with ui.link(target='https://www.reddit.com/r/nicegui/').classes('max-[385px]:hidden').tooltip('Reddit'):
+        with ui.link(target='https://www.reddit.com/r/nicegui/').classes('max-[395px]:hidden').tooltip('Reddit'):
             svg.reddit().classes('fill-white scale-125 m-1')
         with ui.link(target='https://github.com/zauberzeug/nicegui/').tooltip('GitHub'):
             svg.github().classes('fill-white scale-125 m-1')
-        add_star().classes('max-[480px]:hidden')
+        add_star().classes('max-[490px]:hidden')
         with ui.row().classes('lg:hidden'):
             with ui.button().props('flat color=white icon=more_vert round'):
                 with ui.menu().classes('bg-primary text-white text-lg').props(remove='no-parent-event'):

+ 19 - 12
nicegui/elements/tabs.py

@@ -1,4 +1,6 @@
-from typing import Any, Callable, Optional
+from __future__ import annotations
+
+from typing import Any, Callable, Optional, Union
 
 from .. import globals
 from .mixins.disableable_element import DisableableElement
@@ -8,7 +10,7 @@ from .mixins.value_element import ValueElement
 class Tabs(ValueElement):
 
     def __init__(self, *,
-                 value: Any = None,
+                 value: Union[Tab, TabPanel, None] = None,
                  on_change: Optional[Callable[..., Any]] = None,
                  ) -> None:
         """Tabs
@@ -16,11 +18,13 @@ class Tabs(ValueElement):
         This element represents `Quasar's QTabs <https://quasar.dev/vue-components/tabs#qtabs-api>`_ component.
         It contains individual tabs.
 
-        :param value: name of the tab to be initially selected
+        :param value: `ui.tab`, `ui.tab_panel`, or name of the tab to be initially selected
         :param on_change: callback to be executed when the selected tab changes
         """
         super().__init__(tag='q-tabs', value=value, on_value_change=on_change)
-        self.panels: Optional[TabPanels] = None
+
+    def _value_to_model_value(self, value: Any) -> Any:
+        return value._props['name'] if isinstance(value, Tab) or isinstance(value, TabPanel) else value
 
 
 class Tab(DisableableElement):
@@ -29,9 +33,9 @@ class Tab(DisableableElement):
         """Tab
 
         This element represents `Quasar's QTab <https://quasar.dev/vue-components/tabs#qtab-api>`_ component.
-        It is a child of a `Tabs` element.
+        It is a child of a `ui.tabs` element.
 
-        :param name: name of the tab (the value of the `Tabs` element)
+        :param name: name of the tab (will be the value of the `ui.tabs` element)
         :param label: label of the tab (default: `None`, meaning the same as `name`)
         :param icon: icon of the tab (default: `None`)
         """
@@ -47,7 +51,7 @@ class TabPanels(ValueElement):
 
     def __init__(self,
                  tabs: Tabs, *,
-                 value: Any = None,
+                 value: Union[Tab, TabPanel, None] = None,
                  on_change: Optional[Callable[..., Any]] = None,
                  animated: bool = True,
                  ) -> None:
@@ -56,8 +60,8 @@ class TabPanels(ValueElement):
         This element represents `Quasar's QTabPanels <https://quasar.dev/vue-components/tab-panels#qtabpanels-api>`_ component.
         It contains individual tab panels.
 
-        :param tabs: the `Tabs` element that controls this element
-        :param value: name of the tab panel to be initially visible
+        :param tabs: the `ui.tabs` element that controls this element
+        :param value: `ui.tab`, `ui.tab_panel`, or name of the tab panel to be initially visible
         :param on_change: callback to be executed when the visible tab panel changes
         :param animated: whether the tab panels should be animated (default: `True`)
         """
@@ -65,16 +69,19 @@ class TabPanels(ValueElement):
         tabs.bind_value(self, 'value')
         self._props['animated'] = animated
 
+    def _value_to_model_value(self, value: Any) -> Any:
+        return value._props['name'] if isinstance(value, Tab) or isinstance(value, TabPanel) else value
+
 
 class TabPanel(DisableableElement):
 
-    def __init__(self, name: str) -> None:
+    def __init__(self, name: Union[Tab, str]) -> None:
         """Tab Panel
 
         This element represents `Quasar's QTabPanel <https://quasar.dev/vue-components/tab-panels#qtabpanel-api>`_ component.
         It is a child of a `TabPanels` element.
 
-        :param name: name of the tab panel (the value of the `TabPanels` element)
+        :param name: `ui.tab` or the name of a tab element
         """
         super().__init__(tag='q-tab-panel')
-        self._props['name'] = name
+        self._props['name'] = name._props['name'] if isinstance(name, Tab) else name

+ 5 - 5
nicegui/events.py

@@ -1,9 +1,9 @@
 from dataclasses import dataclass
 from inspect import Parameter, signature
-from typing import TYPE_CHECKING, Any, BinaryIO, Callable, Dict, List, Optional, Union
+from typing import TYPE_CHECKING, Any, Awaitable, BinaryIO, Callable, Dict, List, Optional, Union
 
 from . import background_tasks, globals
-from .helpers import KWONLY_SLOTS, is_coroutine
+from .helpers import KWONLY_SLOTS
 
 if TYPE_CHECKING:
     from .client import Client
@@ -271,15 +271,15 @@ class KeyEventArguments(EventArguments):
 def handle_event(handler: Optional[Callable[..., Any]],
                  arguments: Union[EventArguments, Dict], *,
                  sender: Optional['Element'] = None) -> None:
+    if handler is None:
+        return
     try:
-        if handler is None:
-            return
         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:
             result = handler() if no_arguments else handler(arguments)
-        if is_coroutine(handler):
+        if isinstance(result, Awaitable):
             async def wait_for_result():
                 with sender.parent_slot:
                     await result

+ 3 - 3
nicegui/functions/refreshable.py

@@ -6,7 +6,7 @@ from typing_extensions import Self
 from .. import background_tasks, globals
 from ..dependencies import register_component
 from ..element import Element
-from ..helpers import KWONLY_SLOTS, is_coroutine
+from ..helpers import KWONLY_SLOTS, is_coroutine_function
 
 register_component('refreshable', __file__, 'refreshable.js')
 
@@ -19,7 +19,7 @@ class RefreshableTarget:
     kwargs: Dict[str, Any]
 
     def run(self, func: Callable[..., Any]) -> Union[None, Awaitable]:
-        if is_coroutine(func):
+        if is_coroutine_function(func):
             async def wait_for_result() -> None:
                 with self.container:
                     if self.instance is None:
@@ -65,7 +65,7 @@ class refreshable:
                 continue
             target.container.clear()
             result = target.run(self.func)
-            if is_coroutine(self.func):
+            if is_coroutine_function(self.func):
                 assert result is not None
                 if globals.loop and globals.loop.is_running():
                     background_tasks.create(result)

+ 2 - 2
nicegui/functions/timer.py

@@ -4,7 +4,7 @@ from typing import Any, Callable, Optional
 
 from .. import background_tasks, globals
 from ..binding import BindableProperty
-from ..helpers import is_coroutine
+from ..helpers import is_coroutine_function
 from ..slot import Slot
 
 
@@ -81,7 +81,7 @@ class Timer:
         try:
             assert self.callback is not None
             result = self.callback()
-            if is_coroutine(self.callback):
+            if is_coroutine_function(self.callback):
                 await result
         except Exception as e:
             globals.handle_exception(e)

+ 8 - 4
nicegui/helpers.py

@@ -16,9 +16,8 @@ from fastapi.responses import StreamingResponse
 from starlette.middleware import Middleware
 from starlette.middleware.sessions import SessionMiddleware
 
-from nicegui.storage import RequestTrackingMiddleware
-
 from . import background_tasks, globals
+from .storage import RequestTrackingMiddleware
 
 if TYPE_CHECKING:
     from .client import Client
@@ -26,7 +25,12 @@ if TYPE_CHECKING:
 KWONLY_SLOTS = {'kw_only': True, 'slots': True} if sys.version_info >= (3, 10) else {}
 
 
-def is_coroutine(object: Any) -> bool:
+def is_coroutine_function(object: Any) -> bool:
+    """Check if the object is a coroutine function.
+
+    This function is needed because functools.partial is not a coroutine function, but its func attribute is.
+    Note: It will return false for coroutine objects.
+    """
     while isinstance(object, functools.partial):
         object = object.func
     return asyncio.iscoroutinefunction(object)
@@ -96,7 +100,7 @@ def schedule_browser(host: str, port: int) -> Tuple[threading.Thread, threading.
 
 
 def set_storage_secret(storage_secret: Optional[str] = None) -> None:
-    """Set storage_secret for ui.run() and run_with."""
+    """Set storage_secret and add request tracking middleware."""
     if any(m.cls == SessionMiddleware for m in globals.app.user_middleware):
         # NOTE not using "add_middleware" because it would be the wrong order
         globals.app.user_middleware.append(Middleware(RequestTrackingMiddleware))

+ 8 - 0
tests/test_events.py

@@ -26,21 +26,29 @@ async def click_async_with_args(_: ClickEventArguments):
     ui.label('click_async_with_args')
 
 
+async def click_lambda_with_async_and_parameters(msg: str):
+    await asyncio.sleep(0.1)
+    ui.label(f'click_lambda_with_async_and_parameters: {msg}')
+
+
 def test_click_events(screen: Screen):
     ui.button('click_sync_no_args', on_click=click_sync_no_args)
     ui.button('click_sync_with_args', on_click=click_sync_with_args)
     ui.button('click_async_no_args', on_click=click_async_no_args)
     ui.button('click_async_with_args', on_click=click_async_with_args)
+    ui.button('click_lambda_with_async_and_parameters', on_click=lambda: click_lambda_with_async_and_parameters('works'))
 
     screen.open('/')
     screen.click('click_sync_no_args')
     screen.click('click_sync_with_args')
     screen.click('click_async_no_args')
     screen.click('click_async_with_args')
+    screen.click('click_lambda_with_async_and_parameters')
     screen.should_contain('click_sync_no_args')
     screen.should_contain('click_sync_with_args')
     screen.should_contain('click_async_no_args')
     screen.should_contain('click_async_with_args')
+    screen.should_contain('click_lambda_with_async_and_parameters: works')
 
 
 def test_generic_events(screen: Screen):

+ 20 - 1
tests/test_tabs.py

@@ -3,7 +3,7 @@ from nicegui import ui
 from .screen import Screen
 
 
-def test_tabs(screen: Screen):
+def test_with_strings(screen: Screen):
     with ui.tabs() as tabs:
         ui.tab('One')
         ui.tab('Two')
@@ -18,3 +18,22 @@ def test_tabs(screen: Screen):
     screen.should_contain('First tab')
     screen.click('Two')
     screen.should_contain('Second tab')
+
+
+def test_with_tab_objects(screen: Screen):
+    with ui.tabs() as tabs:
+        tab1 = ui.tab('One')
+        tab2 = ui.tab('Two')
+
+    with ui.tab_panels(tabs, value=tab2):
+        with ui.tab_panel(tab1):
+            ui.label('First tab')
+        with ui.tab_panel(tab2):
+            ui.label('Second tab')
+
+    screen.open('/')
+    screen.should_contain('One')
+    screen.should_contain('Two')
+    screen.should_contain('Second tab')
+    screen.click('One')
+    screen.should_contain('First tab')

+ 144 - 0
website/build_search_index.py

@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+import ast
+import json
+import os
+import re
+from _ast import AsyncFunctionDef
+from pathlib import Path
+from typing import List, Optional, Union
+
+from nicegui import app, ui
+
+dir_path = Path(__file__).parent
+os.chdir(dir_path)
+
+
+def ast_string_node_to_string(node):
+    if isinstance(node, ast.Str):
+        return node.s
+    elif isinstance(node, ast.JoinedStr):
+        return ''.join(ast_string_node_to_string(part) for part in node.values)
+    else:
+        return str(ast.unparse(node))
+
+
+def cleanup(markdown_string: str) -> str:
+    # Remove link URLs but keep the description
+    markdown_string = re.sub(r'\[([^\[]+)\]\([^\)]+\)', r'\1', markdown_string)
+    # Remove inline code ticks
+    markdown_string = re.sub(r'`([^`]+)`', r'\1', markdown_string)
+    # Remove code blocks
+    markdown_string = re.sub(r'```([^`]+)```', r'\1', markdown_string)
+    markdown_string = re.sub(r'``([^`]+)``', r'\1', markdown_string)
+    # Remove braces
+    markdown_string = re.sub(r'\{([^\}]+)\}', r'\1', markdown_string)
+    return markdown_string
+
+
+class DocVisitor(ast.NodeVisitor):
+
+    def __init__(self, topic: Optional[str] = None) -> None:
+        super().__init__()
+        self.topic = topic
+        self.current_title = None
+        self.current_content: List[str] = []
+
+    def visit_Call(self, node: ast.Call):
+        if isinstance(node.func, ast.Name):
+            function_name = node.func.id
+        elif isinstance(node.func, ast.Attribute):
+            function_name = node.func.attr
+        else:
+            raise NotImplementedError(f'Unknown function type: {node.func}')
+        if function_name in ['heading', 'subheading']:
+            self.on_new_heading()
+            self.current_title = node.args[0].s
+        elif function_name == 'markdown':
+            if node.args:
+                raw = ast_string_node_to_string(node.args[0]).splitlines()
+                raw = ' '.join(l.strip() for l in raw).strip()
+                self.current_content.append(cleanup(raw))
+        self.generic_visit(node)
+
+    def on_new_heading(self) -> None:
+        if self.current_title:
+            self.add_to_search_index(self.current_title, self.current_content if self.current_content else 'Overview')
+            self.current_content = []
+
+    def visit_AsyncFunctionDef(self, node: AsyncFunctionDef) -> None:
+        self.visit_FunctionDef(node)
+
+    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
+        if node.name == 'main_demo':
+            docstring = ast.get_docstring(node)
+            if docstring is None:
+                api = getattr(ui, self.topic) if hasattr(ui, self.topic) else getattr(app, self.topic)
+                docstring = api.__doc__ or api.__init__.__doc__
+            lines = cleanup(docstring).splitlines()
+            self.add_to_search_index(lines[0], lines[1:], main=True)
+
+        for decorator in node.decorator_list:
+            if isinstance(decorator, ast.Call):
+                function = decorator.func
+                if isinstance(function, ast.Name) and function.id == 'text_demo':
+                    title = decorator.args[0].s
+                    content = cleanup(decorator.args[1].s).splitlines()
+                    self.add_to_search_index(title, content)
+        self.generic_visit(node)
+
+    def add_to_search_index(self, title: str, content: Union[str, list], main: bool = False) -> None:
+        if isinstance(content, list):
+            content_str = ' '.join(l.strip() for l in content).strip()
+        else:
+            content_str = content
+
+        anchor = title.lower().replace(' ', '_')
+        url = f'/documentation/{self.topic or ""}'
+        if not main:
+            url += f'#{anchor}'
+            if self.topic:
+                title = f'{self.topic.replace("_", " ").title()}: {title}'
+        documents.append({
+            'title': title,
+            'content': content_str,
+            'url': url,
+        })
+
+
+class MainVisitor(ast.NodeVisitor):
+
+    def visit_Call(self, node: ast.Call):
+        if isinstance(node.func, ast.Name):
+            function_name = node.func.id
+        elif isinstance(node.func, ast.Attribute):
+            function_name = node.func.attr
+        else:
+            return
+        if function_name == 'example_link':
+            title = ast_string_node_to_string(node.args[0])
+            name = name = title.lower().replace(' ', '_')
+            documents.append({
+                'title': 'Example: ' + title,
+                'content': ast_string_node_to_string(node.args[1]),
+                'url': f'https://github.com/zauberzeug/nicegui/tree/main/examples/{name}/main.py',
+            })
+
+
+def generate_for(file: Path, topic: Optional[str] = None) -> None:
+    tree = ast.parse(file.read_text())
+    doc_visitor = DocVisitor(topic)
+    doc_visitor.visit(tree)
+    if doc_visitor.current_title:
+        doc_visitor.on_new_heading()  # to finalize the last heading
+
+
+documents = []
+tree = ast.parse(Path('../main.py').read_text())
+MainVisitor().visit(tree)
+
+generate_for(Path('./documentation.py'))
+for file in Path('./more_documentation').glob('*.py'):
+    generate_for(file, file.stem.removesuffix('_documentation'))
+
+with open('static/search_index.json', 'w') as f:
+    json.dump(documents, f, indent=2)

+ 1 - 20
website/documentation.py

@@ -166,26 +166,7 @@ def create_full() -> None:
 
     load_demo(ui.expansion)
     load_demo(ui.splitter)
-
-    @text_demo('Tabs', '''
-        The elements `ui.tabs`, `ui.tab`, `ui.tab_panels`, and `ui.tab_panel` resemble
-        [Quasar's tabs](https://quasar.dev/vue-components/tabs)
-        and [tab panels](https://quasar.dev/vue-components/tab-panels) API.
-
-        `ui.tabs` creates a container for the tabs. This could be placed in a `ui.header` for example.
-        `ui.tab_panels` creates a container for the tab panels with the actual content.
-    ''')
-    def tabs_demo():
-        with ui.tabs() as tabs:
-            ui.tab('Home', icon='home')
-            ui.tab('About', icon='info')
-
-        with ui.tab_panels(tabs, value='Home'):
-            with ui.tab_panel('Home'):
-                ui.label('This is the first tab')
-            with ui.tab_panel('About'):
-                ui.label('This is the second tab')
-
+    load_demo('tabs')
     load_demo(ui.menu)
 
     @text_demo('Tooltips', '''

+ 2 - 1
website/documentation_tools.py

@@ -27,6 +27,7 @@ def get_menu() -> ui.left_drawer:
 
 
 def heading(text: str, *, make_menu_entry: bool = True) -> None:
+    ui.link_target(create_anchor_name(text))
     ui.html(f'<em>{text}</em>').classes('mt-8 text-3xl font-weight-500')
     if make_menu_entry:
         with get_menu():
@@ -93,7 +94,7 @@ class element_demo:
         self.element_class = element_class
 
     def __call__(self, f: Callable, *, more_link: Optional[str] = None) -> Callable:
-        doc = self.element_class.__doc__ or self.element_class.__init__.__doc__
+        doc = f.__doc__ or self.element_class.__doc__ or self.element_class.__init__.__doc__
         title, documentation = doc.split('\n', 1)
         with ui.column().classes('w-full mb-8 gap-2'):
             if more_link:

+ 58 - 0
website/more_documentation/tabs_documentation.py

@@ -0,0 +1,58 @@
+from nicegui import ui
+
+from ..documentation_tools import text_demo
+
+
+def main_demo() -> None:
+    """Tabs
+
+    The elements `ui.tabs`, `ui.tab`, `ui.tab_panels`, and `ui.tab_panel` resemble
+    `Quasar's tabs <https://quasar.dev/vue-components/tabs>`_
+    and `tab panels <https://quasar.dev/vue-components/tab-panels>`_ API.
+
+    `ui.tabs` creates a container for the tabs. This could be placed in a `ui.header` for example.
+    `ui.tab_panels` creates a container for the tab panels with the actual content.
+    Each `ui.tab_panel` is associated with a `ui.tab` element.
+    """
+    with ui.tabs().classes('w-full') as tabs:
+        one = ui.tab('One')
+        two = ui.tab('Two')
+    with ui.tab_panels(tabs, value=two).classes('w-full'):
+        with ui.tab_panel(one):
+            ui.label('First tab')
+        with ui.tab_panel(two):
+            ui.label('Second tab')
+
+
+def more() -> None:
+    @text_demo('Name, label, icon', '''
+        The `ui.tab` element has a `label` property that can be used to display a different text than the `name`.
+        The `name` can also be used instead of the `ui.tab` objects to associate a `ui.tab` with a `ui.tab_panel`. 
+        Additionally each tab can have an `icon`.
+    ''')
+    def name_and_label():
+        with ui.tabs() as tabs:
+            ui.tab('h', label='Home', icon='home')
+            ui.tab('a', label='About', icon='info')
+        with ui.tab_panels(tabs, value='h').classes('w-full'):
+            with ui.tab_panel('h'):
+                ui.label('Main Content')
+            with ui.tab_panel('a'):
+                ui.label('Infos')
+
+    @text_demo('Switch tabs programmatically', '''
+        The `ui.tabs` and `ui.tab_panels` elements are derived from ValueElement which has a `set_value` method.
+        That can be used to switch tabs programmatically.
+    ''')
+    def switch_tabs():
+        content = {'Tab 1': 'Content 1', 'Tab 2': 'Content 2', 'Tab 3': 'Content 3'}
+        with ui.tabs() as tabs:
+            for title in content:
+                ui.tab(title)
+        with ui.tab_panels(tabs).classes('w-full') as panels:
+            for title, text in content.items():
+                with ui.tab_panel(title):
+                    ui.label(text)
+
+        ui.button('GoTo 1', on_click=lambda: panels.set_value('Tab 1'))
+        ui.button('GoTo 2', on_click=lambda: tabs.set_value('Tab 2'))

+ 59 - 0
website/search.py

@@ -0,0 +1,59 @@
+from nicegui import events, ui
+
+
+class Search:
+
+    def __init__(self) -> None:
+        ui.add_head_html(r'''
+            <script>
+            async function loadSearchData() {
+                const response = await fetch("/static/search_index.json");
+                if (!response.ok) {
+                    throw new Error(`HTTP error! status: ${response.status}`);
+                }
+                const searchData = await response.json();
+                const options = {
+                    keys: [
+                        { name: "title", weight: 0.7 },
+                        { name: "content", weight: 0.3 },
+                    ],
+                    tokenize: true, // each word is ranked individually
+                    threshold: 0.3,
+                    location: 0,
+                    distance: 10000,
+                };
+                window.fuse = new Fuse(searchData, options);
+            }
+            loadSearchData();
+            </script>
+        ''')
+        with ui.dialog() as self.dialog, ui.card().tight().classes('w-[800px] h-[600px]'):
+            with ui.row().classes('w-full items-center px-4'):
+                ui.icon('search', size='2em')
+                ui.input(placeholder='Search documentation', on_change=self.handle_input) \
+                    .classes('flex-grow').props('borderless autofocus')
+                ui.button('ESC').props('padding="2px 8px" outline size=sm color=grey-5').classes('shadow')
+            ui.separator()
+            self.results = ui.element('q-list').classes('w-full').props('separator')
+        ui.keyboard(self.handle_keypress)
+
+    def create_button(self) -> ui.button:
+        return ui.button(on_click=self.dialog.open).props('flat icon=search color=white')
+
+    def handle_keypress(self, e: events.KeyEventArguments) -> None:
+        if not e.action.keydown:
+            return
+        if e.key == '/':
+            self.dialog.open()
+        if e.key == 'k' and (e.modifiers.ctrl or e.modifiers.meta):
+            self.dialog.open()
+
+    async def handle_input(self, e: events.ValueChangeEventArguments) -> None:
+        self.results.clear()
+        with self.results:
+            for result in await ui.run_javascript(f'return window.fuse.search("{e.value}").slice(0, 50)'):
+                href: str = result['item']['url']
+                target = 'blank' if href.startswith('http') else ''
+                with ui.element('q-item').props(f'clickable href={href} target={target}'):
+                    with ui.element('q-item-section'):
+                        ui.label(result['item']['title'])

+ 1 - 0
website/static/header.html

@@ -21,3 +21,4 @@
     else header.classList.remove("fade");
   };
 </script>
+<script src="https://cdn.jsdelivr.net/npm/fuse.js@6.6.2"></script>

文件差异内容过多而无法显示
+ 303 - 0
website/static/search_index.json


部分文件因为文件数量过多而无法显示