diff --git a/panel/command/serve.py b/panel/command/serve.py index 9ab987f854..a2df0313ec 100644 --- a/panel/command/serve.py +++ b/panel/command/serve.py @@ -213,6 +213,10 @@ class Serve(_BkServe): action = 'store_true', help = "Whether to reuse sessions when serving the initial request.", )), + ('--global-loading-spinner', dict( + action = 'store_true', + help = "Whether to add a global loading spinner to the application(s).", + )), ) # Supported file extensions @@ -281,6 +285,7 @@ def customize_kwargs(self, args, server_kwargs): raise ValueError("rest-provider %r not recognized." % args.rest_provider) config.autoreload = args.autoreload + config.global_loading_spinner = args.global_loading_spinner config.reuse_sessions = args.reuse_sessions if config.autoreload: diff --git a/panel/config.py b/panel/config.py index c25f2917dc..2bce8dd09b 100644 --- a/panel/config.py +++ b/panel/config.py @@ -128,6 +128,9 @@ class _config(_base_config): exception_handler = param.Callable(default=None, doc=""" General exception handler for events.""") + global_loading_spinner = param.Boolean(default=False, doc=""" + Whether to add a global loading spinner for the whole application.""") + load_entry_points = param.Boolean(default=True, doc=""" Load entry points from external packages.""") diff --git a/panel/io/document.py b/panel/io/document.py index c4a8d788a6..d1a5fe0c44 100644 --- a/panel/io/document.py +++ b/panel/io/document.py @@ -17,7 +17,10 @@ from bokeh.application.application import SessionContext from bokeh.document.document import Document from bokeh.document.events import DocumentChangedEvent, ModelChangedEvent +from bokeh.models import CustomJS +from ..config import config +from .loading import LOADING_INDICATOR_CSS_CLASS from .model import monkeypatch_events from .state import curdoc_locked, state @@ -115,6 +118,13 @@ def init_doc(doc: Optional[Document]) -> Document: if thread: state._thread_id_[curdoc] = thread.ident + if config.global_loading_spinner: + doc.js_on_event( + 'document_ready', CustomJS(code=f""" + const body = document.getElementsByTagName('body')[0] + body.classList.remove({LOADING_INDICATOR_CSS_CLASS!r}, {config.loading_spinner!r}) + """) + ) session_id = curdoc.session_context.id sessions = state.session_info['sessions'] if session_id not in sessions: diff --git a/panel/io/pyodide.py b/panel/io/pyodide.py index 04a702cc84..58c1b551d0 100644 --- a/panel/io/pyodide.py +++ b/panel/io/pyodide.py @@ -33,6 +33,7 @@ from ..util import edit_readonly, is_holoviews, isurl from . import resources from .document import MockSessionContext +from .loading import LOADING_INDICATOR_CSS_CLASS from .mime_render import WriteCallbackStream, exec_with_return, format_mime from .state import state @@ -413,7 +414,7 @@ def hide_loader() -> None: from js import document body = document.getElementsByTagName('body')[0] - body.classList.remove("pn-loading", config.loading_spinner) + body.classList.remove(LOADING_INDICATOR_CSS_CLASS, config.loading_spinner) def sync_location(): """ diff --git a/panel/io/resources.py b/panel/io/resources.py index 3a3a5c4b0c..646a5ef2f5 100644 --- a/panel/io/resources.py +++ b/panel/io/resources.py @@ -482,6 +482,9 @@ def css_raw(self): css_txt = f.read() if css_txt not in raw: raw.append(css_txt) + if config.global_loading_spinner: + loading_base = (DIST_DIR / "css" / "loading.css").read_text() + raw.extend([loading_base, loading_css()]) return raw + process_raw_css(config.raw_css) @property diff --git a/panel/io/server.py b/panel/io/server.py index 6b2666fe20..ac0189cc74 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -47,6 +47,7 @@ ) from bokeh.embed.util import RenderItem from bokeh.io import curdoc +from bokeh.models import CustomJS from bokeh.server.server import Server as BokehServer from bokeh.server.urls import per_app_patterns from bokeh.server.views.autoload_js_handler import ( @@ -69,6 +70,7 @@ from ..util import edit_readonly, fullpath from ..util.warnings import warn from .document import init_doc, unlocked, with_lock # noqa +from .loading import LOADING_INDICATOR_CSS_CLASS from .logging import ( LOG_SESSION_CREATED, LOG_SESSION_DESTROYED, LOG_SESSION_LAUNCHING, ) @@ -120,6 +122,14 @@ def _eval_panel( from ..pane import panel as as_panel from ..template import BaseTemplate + if config.global_loading_spinner: + doc.js_on_event( + 'document_ready', CustomJS(code=f""" + const body = document.getElementsByTagName('body')[0] + body.classList.remove({LOADING_INDICATOR_CSS_CLASS!r}, {config.loading_spinner!r}) + """) + ) + # Set up instrumentation for logging sessions logger.info(LOG_SESSION_LAUNCHING, id(doc)) def _log_session_destroyed(session_context): @@ -214,23 +224,33 @@ def server_html_page_for_session( if template is FILE: template = BASE_TEMPLATE - session.document._template_variables['theme_name'] = config.theme - session.document._template_variables['dist_url'] = dist_url - for root in session.document.roots: + doc = session.document + doc._template_variables['theme_name'] = config.theme + doc._template_variables['dist_url'] = dist_url + for root in doc.roots: patch_model_css(root, dist_url=dist_url) render_item = RenderItem( token = token or session.token, - roots = session.document.roots, + roots = doc.roots, use_for_title = False, ) if template_variables is None: template_variables = {} - bundle = bundle_resources(session.document.roots, resources) - return html_page_for_render_items(bundle, {}, [render_item], title, - template=template, template_variables=template_variables) + with set_curdoc(doc): + bundle = bundle_resources(doc.roots, resources) + html = html_page_for_render_items( + bundle, {}, [render_item], title, template=template, + template_variables=template_variables + ) + if config.global_loading_spinner: + html = html.replace( + '', f'' + ) + return html + def autoload_js_script(doc, resources, token, element_id, app_path, absolute_url, absolute=False): resources = Resources.from_bokeh(resources, absolute=absolute) diff --git a/panel/tests/ui/io/test_loading.py b/panel/tests/ui/io/test_loading.py new file mode 100644 index 0000000000..255bd8ad26 --- /dev/null +++ b/panel/tests/ui/io/test_loading.py @@ -0,0 +1,27 @@ +import time + +import pytest + +try: + from playwright.sync_api import expect +except ImportError: + pytestmark = pytest.mark.skip('playwright not available') + +from panel.config import config +from panel.io.server import serve +from panel.pane import Markdown + +pytestmark = pytest.mark.ui + +def test_global_loading_indicator(page, port): + def app(): + config.global_loading_spinner = True + return Markdown('Blah') + + serve(app, port=port, threaded=True, show=False) + + time.sleep(0.5) + + page.goto(f"http://localhost:{port}") + + expect(page.locator("body")).not_to_have_class('pn-loading')