holoviz/panel · #8877

feat: Select the panel.ui design per session and resolve designs at render time

philippjfr · merged Oct 19, 20266 files · 184 + / 11 −
doc/how_to/styling/design.md5 + / 1 −
@@ -24,7 +24,11 @@ from panel.theme import Material pn.config.design = Material ``` -Any component that is rendered will now inherit this design. However, alternatively we can also set a `design` explicitly on a particular component, e.g.:+Any component without a `design` of its own uses the configured design when it is rendered, including components created before the design was set. In a served application the setting applies to the current session, so applications served from the same process can use different designs.++Using `pn.ui` selects the `'material-ui'` design automatically, unless another design has been configured. To keep a different design, set it before using `pn.ui`.++However, alternatively we can also set a `design` explicitly on a particular component, e.g.:  ```{pyodide} from panel.theme import Bootstrap, Material, Native
doc/upgrade.md1 + / 1 −
@@ -19,7 +19,7 @@ pn.ui.widgets.Button(label='Run')   # same class pn.ui.Tabulator(value=pd.DataFrame({'value': [1, 2]}))  # classic component, re-exported unchanged ``` -Material components can be mixed with classic components in an app. Panel 1.9 added `label` as an alias for `name` on classic components, so you can adopt that spelling before switching namespaces. We have made a best effort to keep classic and Material components compatible, but they are not 1:1 everywhere. Check {ref}`panel-ui-compatibility` for known parameter and default differences, and test callback behavior before replacing a classic widget. Importing `panel.ui` selects the Material UI design if you have not explicitly configured another design. To keep a chosen design, set it before importing `panel.ui`.+Material components can be mixed with classic components in an app. Panel 1.9 added `label` as an alias for `name` on classic components, so you can adopt that spelling before switching namespaces. We have made a best effort to keep classic and Material components compatible, but they are not 1:1 everywhere. Check {ref}`panel-ui-compatibility` for known parameter and default differences, and test callback behavior before replacing a classic widget. Using `panel.ui` selects the Material UI design if you have not explicitly configured another design. In a served application only the sessions that use `panel.ui` switch, so classic applications served from the same process keep their design. To keep a chosen design, set it before using `panel.ui`.  #### Migrate Python files 
panel/tests/theme/test_base.py60 + / 1 −
@@ -3,8 +3,10 @@ from bokeh.document import Document from bokeh.models import ImportedStyleSheet +from panel.config import config from panel.io.resources import CDN_DIST, stylesheet_url-from panel.io.state import state+from panel.io.state import set_curdoc, state+from panel.theme import Bootstrap, Material, Native from panel.theme.base import BOKEH_DARK, Design, Inherit from panel.viewable import Viewable from panel.widgets import FloatSlider, IntSlider, TextInput@@ -345,3 +347,60 @@ def test_design_apply_recreates_destroyed_stylesheets(document, comm):             assert stylesheet_url(stylesheet) is not None             assert id(stylesheet) not in stale_ids     assert all(stylesheet_url(sts) is not None for sts in cache.values())+++def test_design_follows_config_after_creation():+    widget = TextInput()++    with config.set(design=Material):+        assert isinstance(widget._design, Material)+    assert isinstance(widget._design, Native)+    assert widget.design is None+++def test_design_follows_session_config_after_creation(server_document):+    widget = TextInput()+    config.design = Material++    assert isinstance(widget._design, Material)+    with set_curdoc(Document()):+        assert isinstance(widget._design, Native)+++def test_design_follows_config_theme():+    widget = TextInput()++    with config.set(design=Material, theme='dark'):+        assert widget._design.theme._name == 'dark'+++def test_explicit_design_does_not_follow_config():+    widget = TextInput(design=Bootstrap)++    with config.set(design=Material):+        assert isinstance(widget._design, Bootstrap)+++def test_explicit_none_design_does_not_follow_config():+    widget = TextInput(design=None)++    with config.set(design=Material):+        assert isinstance(widget._design, Native)+++def test_design_set_to_none_does_not_follow_config():+    widget = TextInput(design=Bootstrap)+    widget.design = None++    with config.set(design=Material):+        assert isinstance(widget._design, Native)+++def test_design_configured_after_creation_applies_at_render(document, comm):+    widget = TextInput()++    with config.set(design=Material):+        model = widget.get_root(document, comm=comm)++    urls = [s.url for s in model.stylesheets if isinstance(s, ImportedStyleSheet)]+    assert any(url.endswith('/bundled/theme/material.css') for url in urls)
panel/tests/ui_components/test_namespace.py59 + / 0 −
@@ -260,6 +260,65 @@ def test_importing_panel_ui_replaces_dependency_default():     assert output == 'True'  +def test_importing_panel_ui_in_a_session_only_switches_that_session():+    output = run_check("""\+    import param+    from bokeh.document import Document++    import panel as pn+    from panel.io.state import set_curdoc++    with set_curdoc(Document()):+        import panel.ui+        material = pn.config.design is pn.ui.MaterialUIDesign+    with set_curdoc(Document()):+        classic = pn.config.design is None+    global_design = param.Parameterized.__getattribute__(pn.config, 'design')++    print(material, classic, global_design is None, end='')+    """)+    assert output == 'True True True'+++def test_later_sessions_accessing_panel_ui_switch():+    output = run_check("""\+    from bokeh.document import Document++    import panel as pn+    from panel.io.state import set_curdoc++    with set_curdoc(Document()):+        import panel.ui+    with set_curdoc(Document()):+        from panel.ui import Button+        from_import = pn.config.design is pn.ui.MaterialUIDesign+    with set_curdoc(Document()):+        pn.extension(design='fast')+        pn.ui.Button+        explicit = pn.config.design.__name__++    print(from_import, explicit, end='')+    """)+    assert output == 'True Fast'+++def test_classic_component_created_before_using_panel_ui_follows_session():+    output = run_check("""\+    from bokeh.document import Document++    import panel as pn+    from panel.io.state import set_curdoc++    with set_curdoc(Document()):+        import panel.ui+    with set_curdoc(Document()):+        widget = pn.widgets.TextInput()+        pn.ui.Button+        print(type(widget._design).__name__, end='')+    """)+    assert output == 'MaterialUIDesign'++ def test_explicit_design_survives_importing_panel_ui():     # panel-material-ui assigns config.design unconditionally at import time;     # panel.ui has to undo that.
panel/ui/__init__.py37 + / 2 −
@@ -12,9 +12,13 @@     from panel.ui import Button     from panel.ui.widgets import Button """+import sys+import types+ import param  from ..config import config+from ..io.state import state   def _global_design():@@ -281,9 +285,40 @@ def _set_global_design(design):     "YT", ) ++def _select_design():+    """+    Selects the Material UI design unless another one is configured. Within a+    session only that session is switched, so classic applications served from+    the same process keep their design.+    """+    if state.curdoc is None:+        if _global_design() is None:+            _set_global_design(MaterialUIDesign)+    elif config.design is None:+        config.design = MaterialUIDesign+++class _DesignSelectingModule(types.ModuleType):+    """+    The module is imported once per process, so the sessions after the first+    select the design whenever they access a component through it.+    """++    def __getattribute__(self, name):+        if not name.startswith('__') and state.curdoc is not None:+            _select_design()+        return super().__getattribute__(name)++ if _global_design() is not _prior_design:     _set_global_design(_prior_design) -if config.design is None or config.design is MaterialDesign:-    # The dependency selects MaterialDesign on import, even without a user choice.+if _prior_design is MaterialDesign:+    # panel-material-ui was imported first and selected its design globally.     _set_global_design(MaterialUIDesign)+else:+    _select_design()+    for _name, _module in list(sys.modules.items()):+        if _name == __name__ or _name.startswith(f'{__name__}.'):+            _module.__class__ = _DesignSelectingModule
panel/viewable.py22 + / 6 −
@@ -318,8 +318,6 @@ def __init__(self, **params):                 params['sizing_mode'] = config.sizing_mode             elif config.sizing_mode == 'stretch_height' and 'height' not in params:                 params['sizing_mode'] = config.sizing_mode-        if 'design' not in params and self.param.design.default is None:-            params['design'] = config.design         super().__init__(**params)         self._sizing_initializing = False         # Track dynamic (post-init) updates to the sizing parameters so@@ -853,12 +851,14 @@ class Viewable(Renderable, Layoutable, ServableMixin):      def __init__(self, **params):         hooks = params.pop('hooks', [])+        # An explicit design=None opts out of the configured design.+        explicit_none = 'design' in params and params['design'] is None         super().__init__(**params)         self._hooks = hooks          if self.loading:             self._update_loading()-        self._update_design()+        self._update_design(explicit_none)         self._internal_callbacks.extend([             self.param.watch(self._update_design, 'design'),             self.param.watch(self._update_loading, 'loading')@@ -869,14 +869,30 @@ def __init__(self, **params):     def _instantiate_design(design: type[Design], theme: str) -> Design:         return design(theme=theme) -    def _update_design(self, *_):+    @property+    def _design(self):+        # Without an assigned design the configuration is read when the+        # component renders, so a design a session selects after creating+        # the component still applies to it.+        design = getattr(self, '_assigned_design', None)+        if design is not None:+            return design+        from .theme.native import Native+        return self._instantiate_design(config.design or Native, config.theme)++    @_design.setter+    def _design(self, design):+        self._assigned_design = design++    def _update_design(self, explicit=True):         from .theme import Design         from .theme.native import Native         if isinstance(self.design, Design):             self._design = self.design+        elif self.design is None:+            self._design = self._instantiate_design(Native, config.theme) if explicit else None         else:-            design = self.design or Native-            self._design = self._instantiate_design(design, config.theme)+            self._design = self._instantiate_design(self.design, config.theme)      def _update_loading(self, *_) -> None:         if self.loading: