holoviz/panel · #8877
feat: Select the panel.ui design per session and resolve designs at render time
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, Nativedoc/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__ = _DesignSelectingModulepanel/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: