diff --git a/examples/outlet/main.py b/examples/outlet/main.py index 0c591b6d2..dabfdd7a4 100644 --- a/examples/outlet/main.py +++ b/examples/outlet/main.py @@ -1,7 +1,7 @@ from nicegui import ui -ui.outlet('/') +@ui.outlet('/') def spa1(): ui.label("spa1 header") yield @@ -9,7 +9,7 @@ def spa1(): # SPA outlet routers can be defined side by side -ui.outlet('/spa2') +@ui.outlet('/spa2') def spa2(): ui.label('spa2') yield @@ -20,11 +20,33 @@ def spa2(): def spa1_index(): ui.label('content of spa1') ui.link('more', '/more') + ui.link('nested', '/nested') + @spa1.view('/more') def spa1_more(): ui.label('more content of spa1') ui.link('main', '/') + ui.link('nested', '/nested') + + +@spa1.outlet('/nested') +def nested(): + ui.label('nested outlet') + yield + + +@nested.view('/') +def nested_index(): + ui.label('content of nested') + ui.link('nested other', '/nested/other') + + +@nested.view('/other') +def nested_other(): + ui.label('other nested') + ui.link('nested index', '/nested') + ''' # the view is a function upon the decorated function of the outlet (same technique as "refreshable.refresh") diff --git a/examples/single_page_router/advanced.py b/examples/single_page_router/advanced.py index 33364dcf8..0da674fce 100644 --- a/examples/single_page_router/advanced.py +++ b/examples/single_page_router/advanced.py @@ -4,7 +4,7 @@ from nicegui import ui from nicegui.page import page -from nicegui.outlet import SinglePageRouter +from nicegui.single_page_router import SinglePageRouter def setup_page_layout(content: Callable): diff --git a/examples/single_page_router/main.py b/examples/single_page_router/main.py index 233433a9a..a42df22bf 100644 --- a/examples/single_page_router/main.py +++ b/examples/single_page_router/main.py @@ -2,7 +2,7 @@ from nicegui import ui from nicegui.page import page -from nicegui.outlet import SinglePageRouter +from nicegui.single_page_router import SinglePageRouter @page('/', title='Welcome!') @@ -17,5 +17,5 @@ def about(): ui.link('Index', '/') -router = SinglePageRouter('/').setup_page_routes() +router = SinglePageRouter('/').reroute_pages() ui.run() diff --git a/nicegui/client.py b/nicegui/client.py index 5332551e2..86760b200 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: from .page import page - from .outlet import Outlet as outlet + from .elements.router_frame import RouterFrame templates = Jinja2Templates(Path(__file__).parent / 'templates') @@ -82,7 +82,7 @@ def __init__(self, page: page, *, shared: bool = False) -> None: self.page = page self.storage = ObservableDict() - self.outlets: Dict[str, "outlet"] = {} + self.single_page_router_frame: Optional[RouterFrame] = None self.connect_handlers: List[Union[Callable[..., Any], Awaitable]] = [] self.disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = [] @@ -227,10 +227,10 @@ async def send_and_wait(): def open(self, target: Union[Callable[..., Any], str], new_tab: bool = False) -> None: """Open a new page in the client.""" path = target if isinstance(target, str) else self.page_routes[target] - for cur_outlet in self.outlets.values(): - target = cur_outlet.resolve_target(target) + for cur_spr in self.single_page_routes.values(): + target = cur_spr.resolve_target(target) if target.valid: - cur_outlet.navigate_to(path) + cur_spr.navigate_to(path) return self.outbox.enqueue_message('open', {'path': path, 'new_tab': new_tab}, self.id) diff --git a/nicegui/elements/router_frame.py b/nicegui/elements/router_frame.py index 65d6e73b1..d63f70855 100644 --- a/nicegui/elements/router_frame.py +++ b/nicegui/elements/router_frame.py @@ -1,7 +1,7 @@ from typing import Union, Callable, Tuple, Any, Optional, Self from nicegui import ui, helpers, context, background_tasks, core -from nicegui.router_frame_url import SinglePageTarget +from nicegui.single_page_target import SinglePageTarget class RouterFrame(ui.element, component='router_frame.js'): diff --git a/nicegui/outlet.py b/nicegui/outlet.py index c74f777a5..eac849dd3 100644 --- a/nicegui/outlet.py +++ b/nicegui/outlet.py @@ -1,242 +1,75 @@ -import fnmatch -import re -from functools import wraps -from typing import Callable, Dict, Union, Optional, Tuple, Self, List, Set, Any, Generator +from typing import Callable, Any, Self, Optional -from fastapi.routing import APIRoute +from nicegui.single_page_router import SinglePageRouter -from nicegui import background_tasks, helpers, ui, core, context -from nicegui.elements.router_frame import RouterFrame -from nicegui.router_frame_url import SinglePageTarget - -class SinglePageRouterEntry: - """The SinglePageRouterEntry is a data class which holds the configuration of a single page router route""" - - def __init__(self, path: str, builder: Callable, title: Union[str, None] = None): - """ - :param path: The path of the route - :param builder: The builder function which is called when the route is opened - :param title: Optional title of the page - """ - self.path = path - self.builder = builder - self.title = title - - def verify(self) -> Self: - """Verifies a SinglePageRouterEntry for correctness. Raises a ValueError if the entry is invalid.""" - path = self.path - if '{' in path: - # verify only a single open and close curly bracket is present - elements = path.split('/') - for cur_element in elements: - if '{' in cur_element: - if cur_element.count('{') != 1 or cur_element.count('}') != 1 or len(cur_element) < 3 or \ - not (cur_element.startswith('{') and cur_element.endswith('}')): - raise ValueError('Only simple path parameters are supported. /path/{value}/{another_value}\n' - f'failed for path: {path}') - return self - - -class SinglePageRouteFrame: - def open(self, target: Union[Callable, str, Tuple[str, bool]]) -> None: - """Open a new page in the browser by exchanging the content of the root page's slot element - - :param target: the target route or builder function. If a list is passed, the second element is a boolean - indicating whether the navigation should be server side only and not update the browser.""" - if isinstance(target, list): - target, server_side = target - - -class Outlet: - """The SinglePageRouter allows the development of a Single Page Application (SPA) which maintains a - persistent connection to the server and only updates the content of the page instead of reloading the whole page. - - This allows faster page switches and a more dynamic user experience because instead of reloading the whole page, - only the content area is updated. The SinglePageRouter is a high-level abstraction which manages the routing - and content area of the SPA. - - For examples see examples/single_page_router""" +class Outlet(SinglePageRouter): + """An outlet function defines the page layout of a single page application into which dynamic content can be + inserted. It is a high-level abstraction which manages the routing and content area of the SPA.""" def __init__(self, path: str, browser_history: bool = True, - included: Union[List[Union[Callable, str]], str, Callable] = '/*', - excluded: Union[List[Union[Callable, str]], str, Callable] = '', on_instance_created: Optional[Callable] = None, **kwargs) -> None: """ :param path: the base path of the single page router. + :param layout_builder: A layout builder function which defines the layout of the page. The layout builder + must be a generator function and contain a yield statement to separate the layout from the content area. :param browser_history: Optional flag to enable or disable the browser history management. Default is True. - :param included: Optional list of masks and callables of paths to include. Default is "/*" which includes all. - If you do not want to include all relative paths, you can specify a list of masks or callables to refine the - included paths. If a callable is passed, it must be decorated with a page. - :param excluded: Optional list of masks and callables of paths to exclude. Default is "" which excludes none. - Explicitly included paths (without wildcards) and Callables are always included, even if they match an - exclusion mask. :param on_instance_created: Optional callback which is called when a new instance is created. Each browser tab or window is a new instance. This can be used to initialize the state of the application. + :param parent: The parent outlet of this outlet. :param kwargs: Additional arguments for the @page decorators """ - super().__init__() - self.routes: Dict[str, SinglePageRouterEntry] = {} - self.base_path = path - # list of masks and callables of paths to include - self.included: List[Union[Callable, str]] = [included] if not isinstance(included, list) else included - # list of masks and callables of paths to exclude - self.excluded: List[Union[Callable, str]] = [excluded] if not isinstance(excluded, list) else excluded - # low level system paths which are excluded by default - self.system_excluded = ['/docs', '/redoc', '/openapi.json', '_*'] - # set of all registered paths which were finally included for verification w/ mask matching in the browser - self.included_paths: Set[str] = set() - self.on_instance_created: Optional[Callable] = on_instance_created - self.use_browser_history = browser_history - self._setup_configured = False - self.outlet_builder: Optional[Callable] = None - self.page_kwargs = kwargs + super().__init__(path, browser_history=browser_history, on_instance_created=on_instance_created, **kwargs) def __call__(self, func: Callable[..., Any]) -> Self: - """Decorator for the outlet function""" + """Decorator for the layout builder / "outlet" function""" + def outlet_view(): + self.setup_content_area() + self.outlet_builder = func - self._setup_routing_pages() + if self.parent_router is None: + self.setup_pages() + else: + relative_path = self.base_path[len(self.parent_router.base_path):] + OutletView(self.parent_router, relative_path)(outlet_view) return self - def _setup_routing_pages(self): - @ui.page(self.base_path, **self.page_kwargs) - @ui.page(f'{self.base_path}' + '{_:path}', **self.page_kwargs) # all other pages - async def root_page(): - self._setup_content_area() - - def view(self, path: str) -> 'OutletView': - """Decorator for the view function""" - return OutletView(path, self) - - def setup_page_routes(self, **kwargs): - """Registers the SinglePageRouter with the @page decorator to handle all routes defined by the router - - :param kwargs: Additional arguments for the @page decorators + def view(self, path: str, title: Optional[str] = None) -> 'OutletView': + """Decorator for the view function + :param path: The path of the view, relative to the base path of the outlet + :param title: Optional title of the view. If a title is set, it will be displayed in the browser tab + when the view is active, otherwise the default title of the application is displayed. """ - if self._setup_configured: - raise ValueError('The SinglePageRouter is already configured') - self._setup_configured = True - self._update_masks() - self._find_api_routes() + return OutletView(self, path, title=title) - def add_page(self, path: str, builder: Callable, title: Optional[str] = None) -> None: - """Add a new route to the single page router + def outlet(self, path: str) -> 'Outlet': + """Defines a nested outlet - :param path: The path of the route - :param builder: The builder function - :param title: Optional title of the page + :param path: The relative path of the outlet """ - self.included_paths.add(path.rstrip('/')) - self.routes[path] = SinglePageRouterEntry(path, builder, title).verify() - - def add_router_entry(self, entry: SinglePageRouterEntry) -> None: - """Adds a fully configured SinglePageRouterEntry to the router - - :param entry: The SinglePageRouterEntry to add - """ - self.routes[entry.path] = entry.verify() - - def resolve_target(self, target: Union[Callable, str]) -> SinglePageTarget: - """Tries to resolve a target such as a builder function or an URL path w/ route and query parameters. + abs_path = self.base_path.rstrip('/') + path + return Outlet(abs_path, parent=self) - :param target: The URL path to open or a builder function - :return: The resolved target. Defines .valid if the target is valid - """ - if isinstance(target, Callable): - for target, entry in self.routes.items(): - if entry.builder == target: - return SinglePageTarget(entry=entry) - else: - parser = SinglePageTarget(target) - return parser.parse_single_page_route(self.routes, target) - def navigate_to(self, target: Union[Callable, str, SinglePageTarget]) -> bool: - """Navigate to a target +class OutletView: + """Defines a single view / "content page" which is displayed in an outlet""" - :param target: The target to navigate to + def __init__(self, parent_outlet: SinglePageRouter, path: str, title: Optional[str] = None): """ - if not isinstance(target, SinglePageTarget): - target = self.resolve_target(target) - if not target.valid: - return False - # TODO find right content area - return True - - def _setup_content_area(self): - """Setups the content area for the single page router - - :return: The content area element + :param parent_outlet: The parent outlet in which this view is displayed + :param path: The path of the view, relative to the base path of the outlet + :param title: Optional title of the view. If a title is set, it will be displayed in the browser tab + when the view is active, otherwise the default title of the application is displayed. """ - frame = self.outlet_builder() - next(frame) # execute top layout components till first yield - content = RouterFrame(list(self.included_paths), self.use_browser_history) # exchangeable content of the page - content.on_resolve(self.resolve_target) - while True: # execute the rest of the outlets ui setup yield by yield - try: - next(frame) - except StopIteration: - break - - def _is_excluded(self, path: str) -> bool: - """Checks if a path is excluded by the exclusion masks - - :param path: The path to check - :return: True if the path is excluded, False otherwise""" - for element in self.included: - if path == element: # if it is a perfect, explicit match: allow - return False - if fnmatch.fnmatch(path, element): # if it is just a mask match: verify it is not excluded - for ex_element in self.excluded: - if fnmatch.fnmatch(path, ex_element): - return True # inclusion mask matched but also exclusion mask - return False # inclusion mask matched - return True # no inclusion mask matched - - def _update_masks(self) -> None: - """Updates the inclusion and exclusion masks and resolves Callables to the actual paths""" - from nicegui.page import Client - for cur_list in [self.included, self.excluded]: - for index, element in enumerate(cur_list): - if isinstance(element, Callable): - if element in Client.page_routes: - cur_list[index] = Client.page_routes[element] - else: - raise ValueError( - f'Invalid target page in inclusion/exclusion list, no @page assigned to element') - - def _find_api_routes(self) -> None: - """Find all API routes already defined via the @page decorator, remove them and redirect them to the - single page router""" - from nicegui.page import Client - page_routes = set() - for key, route in Client.page_routes.items(): - if route.startswith(self.base_path) and not self._is_excluded(route): - page_routes.add(route) - Client.single_page_routes[route] = self - title = None - if key in Client.page_configs: - title = Client.page_configs[key].title - route = route.rstrip('/') - self.add_router_entry(SinglePageRouterEntry(route, builder=key, title=title)) - # /site/{value}/{other_value} --> /site/*/* for easy matching in JavaScript - route_mask = re.sub(r'{[^}]+}', '*', route) - self.included_paths.add(route_mask) - for route in core.app.routes.copy(): - if isinstance(route, APIRoute): - if route.path in page_routes: - core.app.routes.remove(route) - - -class OutletView: - - def __init__(self, path: str, parent_outlet: Outlet): self.path = path + self.title = title self.parent_outlet = parent_outlet def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]: - self.parent_outlet.add_page( - self.parent_outlet.base_path.rstrip('/') + self.path, func) + """Decorator for the view function""" + self.parent_outlet.add_view( + self.parent_outlet.base_path.rstrip('/') + self.path, func, title=self.title) return func diff --git a/nicegui/page_layout.py b/nicegui/page_layout.py index dab4519f3..5e30dca46 100644 --- a/nicegui/page_layout.py +++ b/nicegui/page_layout.py @@ -272,7 +272,7 @@ def __init__(self, position: PageStickyPositions = 'bottom-right', x_offset: flo def _check_current_slot(element: Element) -> None: parent = context.get_slot().parent - if parent != parent.client.content and parent != parent.client.single_page_content: + if parent != parent.client.content: log.warning(f'Found top level layout element "{element.__class__.__name__}" inside element "{parent.__class__.__name__}". ' 'Top level layout elements should not be nested but must be direct children of the page content. ' 'This will be raising an exception in NiceGUI 1.5') # DEPRECATED diff --git a/nicegui/single_page_app.py b/nicegui/single_page_app.py new file mode 100644 index 000000000..f5467c7de --- /dev/null +++ b/nicegui/single_page_app.py @@ -0,0 +1,81 @@ +import fnmatch +from typing import Union, List, Callable, re + +from fastapi.routing import APIRoute + +from nicegui import core +from nicegui.single_page_router import SinglePageRouter, SinglePageRouterEntry + + +class SinglePageApp: + + def __init__(self, + target: SinglePageRouter, + included: Union[List[Union[Callable, str]], str, Callable] = '/*', + excluded: Union[List[Union[Callable, str]], str, Callable] = '') -> None: + """ + :param included: Optional list of masks and callables of paths to include. Default is "/*" which includes all. + If you do not want to include all relative paths, you can specify a list of masks or callables to refine the + included paths. If a callable is passed, it must be decorated with a page. + :param excluded: Optional list of masks and callables of paths to exclude. Default is "" which excludes none. + Explicitly included paths (without wildcards) and Callables are always included, even if they match an + exclusion mask. + """ + self.spr = target + self.included: List[Union[Callable, str]] = [included] if not isinstance(included, list) else included + self.excluded: List[Union[Callable, str]] = [excluded] if not isinstance(excluded, list) else excluded + self.system_excluded = ['/docs', '/redoc', '/openapi.json', '_*'] + + def reroute_pages(self): + """Registers the SinglePageRouter with the @page decorator to handle all routes defined by the router""" + self._update_masks() + self._find_api_routes() + + def is_excluded(self, path: str) -> bool: + """Checks if a path is excluded by the exclusion masks + + :param path: The path to check + :return: True if the path is excluded, False otherwise""" + for inclusion_mask in self.included: + if path == inclusion_mask: # if it is a perfect, explicit match: allow + return False + if fnmatch.fnmatch(path, inclusion_mask): # if it is just a mask match: verify it is not excluded + for ex_element in self.excluded: + if fnmatch.fnmatch(path, ex_element): + return True # inclusion mask matched but also exclusion mask + return False # inclusion mask matched + return True # no inclusion mask matched + + def _update_masks(self) -> None: + """Updates the inclusion and exclusion masks and resolves Callables to the actual paths""" + from nicegui.page import Client + for cur_list in [self.included, self.excluded]: + for index, element in enumerate(cur_list): + if isinstance(element, Callable): + if element in Client.page_routes: + cur_list[index] = Client.page_routes[element] + else: + raise ValueError( + f'Invalid target page in inclusion/exclusion list, no @page assigned to element') + + def _find_api_routes(self) -> None: + """Find all API routes already defined via the @page decorator, remove them and redirect them to the + single page router""" + from nicegui.page import Client + page_routes = set() + base_path = self.spr.base_path + for key, route in Client.page_routes.items(): + if route.startswith(base_path) and not self.is_excluded(route): + page_routes.add(route) + Client.single_page_routes[route] = self + title = None + if key in Client.page_configs: + title = Client.page_configs[key].title + route = route.rstrip('/') + self.spr.add_router_entry(SinglePageRouterEntry(route, builder=key, title=title)) + route_mask = SinglePageRouterEntry.create_path_mask(route) + self.spr.included_paths.add(route_mask) + for route in core.app.routes.copy(): + if isinstance(route, APIRoute): + if route.path in page_routes: + core.app.routes.remove(route) diff --git a/nicegui/router_frame_url.py b/nicegui/single_page_target.py similarity index 98% rename from nicegui/router_frame_url.py rename to nicegui/single_page_target.py index b3617751a..9e2bb5e60 100644 --- a/nicegui/router_frame_url.py +++ b/nicegui/single_page_target.py @@ -3,7 +3,7 @@ from typing import Dict, Optional, TYPE_CHECKING, Self if TYPE_CHECKING: - from nicegui.outlet import SinglePageRouterEntry + from nicegui.single_page_router import SinglePageRouterEntry class SinglePageTarget: