"""
Defines Layout classes which may be used to arrange panes and widgets
in flexible ways to build complex dashboards.
"""
from __future__ import annotations
from collections import defaultdict, namedtuple
from typing import (
TYPE_CHECKING, Any, ClassVar, Dict, Iterable, Iterator, List, Mapping,
Optional, Tuple, Type,
)
import param
from bokeh.models import Row as BkRow
from param.parameterized import iscoroutinefunction, resolve_ref
from ..io.document import freeze_doc
from ..io.model import hold
from ..io.resources import CDN_DIST
from ..io.state import state
from ..models import Column as PnColumn
from ..reactive import Reactive
from ..util import param_name, param_reprs, param_watchers
if TYPE_CHECKING:
from bokeh.document import Document
from bokeh.model import Model
from pyviz_comms import Comm
from ..viewable import Viewable
_row = namedtuple("row", ["children"]) # type: ignore
_col = namedtuple("col", ["children"]) # type: ignore
[docs]class Panel(Reactive):
"""
Abstract baseclass for a layout of Viewables.
"""
# Used internally to optimize updates
_batch_update: ClassVar[bool] = False
# Bokeh model used to render this Panel
_bokeh_model: ClassVar[Type[Model]]
# Direction the layout flows in
_direction: ClassVar[str | None] = None
# Parameters which require the preprocessors to be re-run
_preprocess_params: ClassVar[List[str]] = []
# Parameter -> Bokeh property renaming
_rename: ClassVar[Mapping[str, str | None]] = {'objects': 'children'}
__abstract = True
def __repr__(self, depth: int = 0, max_depth: int = 10) -> str:
if depth > max_depth:
return '...'
spacer = '\n' + (' ' * (depth+1))
cls = type(self).__name__
params = param_reprs(self, ['objects'])
objs = ['[%d] %s' % (i, obj.__repr__(depth+1)) for i, obj in enumerate(self)]
if not params and not objs:
return super().__repr__(depth+1)
elif not params:
template = '{cls}{spacer}{objs}'
elif not objs:
template = '{cls}({params})'
else:
template = '{cls}({params}){spacer}{objs}'
return template.format(
cls=cls, params=', '.join(params),
objs=('%s' % spacer).join(objs), spacer=spacer)
#----------------------------------------------------------------
# Callback API
#----------------------------------------------------------------
def _update_model(
self, events: Dict[str, param.parameterized.Event], msg: Dict[str, Any],
root: Model, model: Model, doc: Document, comm: Optional[Comm]
) -> None:
msg = dict(msg)
inverse = {v: k for k, v in self._property_mapping.items() if v is not None}
preprocess = any(inverse.get(k, k) in self._preprocess_params for k in msg)
# ALERT: Find a better way to handle this
if 'styles' in msg and root is model and 'overflow-x' in msg['styles']:
del msg['styles']['overflow-x']
obj_key = self._property_mapping['objects']
update_children = obj_key in msg
if update_children:
old = events['objects'].old
children, old_children = self._get_objects(model, old, doc, root, comm)
msg[obj_key] = children
msg.update(self._compute_sizing_mode(
children,
dict(
sizing_mode=msg.get('sizing_mode', model.sizing_mode),
styles=msg.get('styles', model.styles),
width=msg.get('width', model.width),
min_width=msg.get('min_width', model.min_width),
margin=msg.get('margin', model.margin)
)
))
else:
old_children = None
with hold(doc):
update = Panel._batch_update
Panel._batch_update = True
try:
with freeze_doc(doc, model, msg, force=update_children):
super()._update_model(events, msg, root, model, doc, comm)
if update:
return
from ..io import state
ref = root.ref['id']
if ref in state._views and preprocess:
state._views[ref][0]._preprocess(root, self, old_children)
finally:
Panel._batch_update = update
#----------------------------------------------------------------
# Model API
#----------------------------------------------------------------
def _get_objects(
self, model: Model, old_objects: List[Viewable], doc: Document,
root: Model, comm: Optional[Comm] = None
):
"""
Returns new child models for the layout while reusing unchanged
models and cleaning up any dropped objects.
"""
from ..pane.base import RerenderError, panel
new_models, old_models = [], []
for i, pane in enumerate(self.objects):
pane = panel(pane)
self.objects[i] = pane
for obj in old_objects:
if obj not in self.objects:
obj._cleanup(root)
current_objects = list(self.objects)
ref = root.ref['id']
for i, pane in enumerate(self.objects):
if pane in old_objects and ref in pane._models:
child, _ = pane._models[root.ref['id']]
old_models.append(child)
else:
try:
child = pane._get_model(doc, root, model, comm)
except RerenderError as e:
if e.layout is not None and e.layout is not self:
raise e
e.layout = None
return self._get_objects(model, current_objects[:i], doc, root, comm)
new_models.append(child)
return new_models, old_models
def _get_model(
self, doc: Document, root: Optional[Model] = None,
parent: Optional[Model] = None, comm: Optional[Comm] = None
) -> Model:
if self._bokeh_model is None:
raise ValueError(f'{type(self).__name__} did not define a _bokeh_model.')
model = self._bokeh_model()
root = root or model
self._models[root.ref['id']] = (model, parent)
objects, _ = self._get_objects(model, [], doc, root, comm)
props = self._get_properties(doc)
props[self._property_mapping['objects']] = objects
props.update(self._compute_sizing_mode(objects, props))
model.update(**props)
self._link_props(model, self._linked_properties, doc, root, comm)
return model
def _compute_sizing_mode(self, children, props):
"""
Handles inference of correct layout sizing mode by inspecting
the children and adapting to their layout properties. This
aims to provide a layer of backward compatibility for the
layout behavior before v1.0 and provide general usability
improvements.
The code iterates over the children and extracts their sizing_mode,
width and height. Based on these values we infer a few overrides
for the container sizing_mode, width and height:
- If a child is responsive in width then the container should
also be responsive in width (unless it has a fixed size).
- If a container is vertical (e.g. a Column) and a child is
responsive in height then the container should also be
responsive.
- If a container is horizontal (e.g. a Row) and all children
are responsive in height then the container should also be
responsive. This behavior is asymmetrical with height
because there isn't always vertical space to expand into
and it is better for the component to match the height of
the other children.
- Always compute the fixed sizes of the children (if available)
and provide this as min_width and min_height settings to
ensure sufficient space is available.
"""
margin = props.get('margin', self.margin)
sizing_mode = props.get('sizing_mode', self.sizing_mode)
if sizing_mode == 'fixed':
return {}
# Iterate over children and determine responsiveness along
# each axis, scaling and the widths of each component.
heights, widths = [], []
all_expand_height, expand_width, expand_height, scale = True, False, False, False
for child in children:
smode = child.sizing_mode
if smode and 'scale' in smode:
scale = True
width_expanded = smode in ('stretch_width', 'stretch_both', 'scale_width', 'scale_both')
height_expanded = smode in ('stretch_height', 'stretch_both', 'scale_height', 'scale_both')
expand_width |= width_expanded
expand_height |= height_expanded
if width_expanded:
width = child.min_width
else:
width = child.width
if not child.width:
width = child.min_width
if width:
if isinstance(margin, tuple):
if len(margin) == 2:
width += margin[1]*2
else:
width += margin[1] + margin[3]
else:
width += margin*2
widths.append(width)
if height_expanded:
height = child.min_height
else:
height = child.height
if height:
all_expand_height = False
else:
height = child.min_height
if height:
if isinstance(margin, tuple):
if len(margin) == 2:
height += margin[0]*2
else:
height += margin[0] + margin[2]
else:
height += margin*2
heights.append(height)
# Infer new sizing mode based on children
mode = 'scale' if scale else 'stretch'
if self._direction == 'horizontal':
allow_height_scale = all_expand_height
else:
allow_height_scale = True
if expand_width and expand_height and not self.width and not self.height:
if allow_height_scale or 'both' in (sizing_mode or ''):
sizing_mode = f'{mode}_both'
else:
sizing_mode = f'{mode}_width'
elif expand_width and not self.width:
sizing_mode = f'{mode}_width'
elif expand_height and not self.height and allow_height_scale:
sizing_mode = f'{mode}_height'
if sizing_mode is None:
return {'sizing_mode': props.get('sizing_mode')}
properties = {'sizing_mode': sizing_mode}
if ((sizing_mode.endswith('_width') or sizing_mode.endswith('_both')) and
widths and 'min_width' not in properties):
width_op = max if self._direction == 'vertical' else sum
min_width = width_op(widths)
op_widths = [min_width]
if 'max_width' in properties:
op_widths.append(properties['max_width'])
properties['min_width'] = min(op_widths)
if ((sizing_mode.endswith('_height') or sizing_mode.endswith('_both')) and
heights and 'min_height' not in properties):
height_op = max if self._direction == 'horizontal' else sum
min_height = height_op(heights)
op_heights = [min_height]
if 'max_height' in properties:
op_heights.append(properties['max_height'])
properties['min_height'] = min(op_heights)
return properties
#----------------------------------------------------------------
# Public API
#----------------------------------------------------------------
[docs] def get_root(
self, doc: Optional[Document] = None, comm: Optional[Comm] = None,
preprocess: bool = True
) -> Model:
root = super().get_root(doc, comm, preprocess)
# ALERT: Find a better way to handle this
if hasattr(root, 'styles') and 'overflow-x' in root.styles:
del root.styles['overflow-x']
return root
[docs] def select(self, selector=None):
"""
Iterates over the Viewable and any potential children in the
applying the Selector.
Arguments
---------
selector: type or callable or None
The selector allows selecting a subset of Viewables by
declaring a type or callable function to filter by.
Returns
-------
viewables: list(Viewable)
"""
objects = super().select(selector)
for obj in self:
objects += obj.select(selector)
return objects
[docs]class ListLike(param.Parameterized):
objects = param.List(default=[], doc="""
The list of child objects that make up the layout.""")
_preprocess_params: ClassVar[List[str]] = ['objects']
def __getitem__(self, index: int | slice) -> Viewable | List[Viewable]:
return self.objects[index]
def __len__(self) -> int:
return len(self.objects)
def __iter__(self) -> Iterator[Viewable]:
for obj in self.objects:
yield obj
def __iadd__(self, other: Iterable[Any]) -> 'ListLike':
self.extend(other)
return self
def __add__(self, other: Iterable[Any]) -> 'ListLike':
if isinstance(other, ListLike):
other = other.objects
else:
other = list(other)
return self.clone(*(self.objects+other))
def __radd__(self, other: Iterable[Any]) -> 'ListLike':
if isinstance(other, ListLike):
other = other.objects
else:
other = list(other)
return self.clone(*(other+self.objects))
def __contains__(self, obj: Viewable) -> bool:
return obj in self.objects
def __setitem__(self, index: int | slice, panes: Iterable[Any]) -> None:
from ..pane import panel
new_objects = list(self)
if not isinstance(index, slice):
start, end = index, index+1
if start > len(self.objects):
raise IndexError('Index %d out of bounds on %s '
'containing %d objects.' %
(end, type(self).__name__, len(self.objects)))
panes = [panes]
else:
start = index.start or 0
end = len(self) if index.stop is None else index.stop
if index.start is None and index.stop is None:
if not isinstance(panes, list):
raise IndexError('Expected a list of objects to '
'replace the objects in the %s, '
'got a %s type.' %
(type(self).__name__, type(panes).__name__))
expected = len(panes)
new_objects = [None]*expected # type: ignore
end = expected
elif end > len(self.objects):
raise IndexError('Index %d out of bounds on %s '
'containing %d objects.' %
(end, type(self).__name__, len(self.objects)))
else:
expected = end-start
if not isinstance(panes, list) or len(panes) != expected:
raise IndexError('Expected a list of %d objects to set '
'on the %s to match the supplied slice.' %
(expected, type(self).__name__))
for i, pane in zip(range(start, end), panes):
new_objects[i] = panel(pane)
self.objects = new_objects
[docs] def clone(self, *objects: Any, **params: Any) -> 'ListLike':
"""
Makes a copy of the layout sharing the same parameters.
Arguments
---------
objects: Objects to add to the cloned layout.
params: Keyword arguments override the parameters on the clone.
Returns
-------
Cloned layout object
"""
if not objects:
if 'objects' in params:
objects = params.pop('objects')
else:
objects = self.objects
elif 'objects' in params:
raise ValueError("A %s's objects should be supplied either "
"as arguments or as a keyword, not both."
% type(self).__name__)
p = dict(self.param.values(), **params)
del p['objects']
return type(self)(*objects, **p)
[docs] def append(self, obj: Any) -> None:
"""
Appends an object to the layout.
Arguments
---------
obj (object): Panel component to add to the layout.
"""
from ..pane import panel
new_objects = list(self)
new_objects.append(panel(obj))
self.objects = new_objects
[docs] def clear(self) -> List[Viewable]:
"""
Clears the objects on this layout.
Returns
-------
objects (list[Viewable]): List of cleared objects.
"""
objects = self.objects
self.objects = []
return objects
[docs] def extend(self, objects: Iterable[Any]) -> None:
"""
Extends the objects on this layout with a list.
Arguments
---------
objects (list): List of panel components to add to the layout.
"""
from ..pane import panel
new_objects = list(self)
new_objects.extend(list(map(panel, objects)))
self.objects = new_objects
[docs] def index(self, object) -> int:
"""
Returns the integer index of the supplied object in the list of objects.
Arguments
---------
obj (object): Panel component to look up the index for.
Returns
-------
index (int): Integer index of the object in the layout.
"""
return self.objects.index(object)
[docs] def insert(self, index: int, obj: Any) -> None:
"""
Inserts an object in the layout at the specified index.
Arguments
---------
index (int): Index at which to insert the object.
object (object): Panel components to insert in the layout.
"""
from ..pane import panel
new_objects = list(self)
new_objects.insert(index, panel(obj))
self.objects = new_objects
[docs] def pop(self, index: int) -> Viewable:
"""
Pops an item from the layout by index.
Arguments
---------
index (int): The index of the item to pop from the layout.
"""
new_objects = list(self)
obj = new_objects.pop(index)
self.objects = new_objects
return obj
[docs] def remove(self, obj: Viewable) -> None:
"""
Removes an object from the layout.
Arguments
---------
obj (object): The object to remove from the layout.
"""
new_objects = list(self)
new_objects.remove(obj)
self.objects = new_objects
[docs] def reverse(self) -> None:
"""
Reverses the objects in the layout.
"""
new_objects = list(self)
new_objects.reverse()
self.objects = new_objects
[docs]class NamedListLike(param.Parameterized):
objects = param.List(default=[], doc="""
The list of child objects that make up the layout.""")
_preprocess_params: ClassVar[List[str]] = ['objects']
def __init__(self, *items: List[Any | Tuple[str, Any]], **params: Any):
if 'objects' in params:
if items:
raise ValueError('%s objects should be supplied either '
'as positional arguments or as a keyword, '
'not both.' % type(self).__name__)
items = params.pop('objects')
params['objects'], self._names = self._to_objects_and_names(items)
super().__init__(**params)
self._panels = defaultdict(dict)
self.param.watch(self._update_names, 'objects')
# ALERT: Ensure that name update happens first, should be
# replaced by watch precedence support in param
param_watchers(self)['objects']['value'].reverse()
def _to_object_and_name(self, item):
from ..pane import panel
if isinstance(item, tuple):
name, item = item
else:
name = getattr(item, 'name', None)
pane = panel(item, name=name)
name = param_name(pane.name) if name is None else name
return pane, name
def _to_objects_and_names(self, items):
objects, names = [], []
for item in items:
pane, name = self._to_object_and_name(item)
objects.append(pane)
names.append(name)
return objects, names
def _update_names(self, event: param.parameterized.Event) -> None:
if len(event.new) == len(self._names):
return
names = []
for obj in event.new:
if obj in event.old:
index = event.old.index(obj)
name = self._names[index]
else:
name = obj.name
names.append(name)
self._names = names
def _update_active(self, *events: param.parameterized.Event) -> None:
pass
#----------------------------------------------------------------
# Public API
#----------------------------------------------------------------
def __getitem__(self, index) -> Viewable | List[Viewable]:
return self.objects[index]
def __len__(self) -> int:
return len(self.objects)
def __iter__(self) -> Iterator[Viewable]:
for obj in self.objects:
yield obj
def __iadd__(self, other: Iterable[Any]) -> 'NamedListLike':
self.extend(other)
return self
def __add__(self, other: Iterable[Any]) -> 'NamedListLike':
if isinstance(other, NamedListLike):
added = list(zip(other._names, other.objects))
elif isinstance(other, ListLike):
added = other.objects
else:
added = list(other)
objects = list(zip(self._names, self.objects))
return self.clone(*(objects+added))
def __radd__(self, other: Iterable[Any]) -> 'NamedListLike':
if isinstance(other, NamedListLike):
added = list(zip(other._names, other.objects))
elif isinstance(other, ListLike):
added = other.objects
else:
added = list(other)
objects = list(zip(self._names, self.objects))
return self.clone(*(added+objects))
def __setitem__(self, index: int | slice, panes: Iterable[Any]) -> None:
new_objects = list(self)
if not isinstance(index, slice):
if index > len(self.objects):
raise IndexError('Index %d out of bounds on %s '
'containing %d objects.' %
(index, type(self).__name__, len(self.objects)))
start, end = index, index+1
panes = [panes]
else:
start = index.start or 0
end = len(self.objects) if index.stop is None else index.stop
if index.start is None and index.stop is None:
if not isinstance(panes, list):
raise IndexError('Expected a list of objects to '
'replace the objects in the %s, '
'got a %s type.' %
(type(self).__name__, type(panes).__name__))
expected = len(panes)
new_objects = [None]*expected # type: ignore
self._names = [None]*len(panes)
end = expected
else:
expected = end-start
if end > len(self.objects):
raise IndexError('Index %d out of bounds on %s '
'containing %d objects.' %
(end, type(self).__name__, len(self.objects)))
if not isinstance(panes, list) or len(panes) != expected:
raise IndexError('Expected a list of %d objects to set '
'on the %s to match the supplied slice.' %
(expected, type(self).__name__))
for i, pane in zip(range(start, end), panes):
new_objects[i], self._names[i] = self._to_object_and_name(pane)
self.objects = new_objects
[docs] def clone(self, *objects: Any, **params: Any) -> 'NamedListLike':
"""
Makes a copy of the Tabs sharing the same parameters.
Arguments
---------
objects: Objects to add to the cloned Tabs object.
params: Keyword arguments override the parameters on the clone.
Returns
-------
Cloned Tabs object
"""
if objects:
overrides = objects
elif 'objects' in params:
raise ValueError('Tabs objects should be supplied either '
'as positional arguments or as a keyword, '
'not both.')
elif 'objects' in params:
overrides = params.pop('objects')
else:
overrides = tuple(zip(self._names, self.objects))
p = dict(self.param.values(), **params)
del p['objects']
return type(self)(*overrides, **params)
[docs] def append(self, pane: Any) -> None:
"""
Appends an object to the tabs.
Arguments
---------
obj (object): Panel component to add as a tab.
"""
new_object, new_name = self._to_object_and_name(pane)
new_objects = list(self)
new_objects.append(new_object)
self._names.append(new_name)
self.objects = new_objects
[docs] def clear(self) -> None:
"""
Clears the tabs.
"""
self._names = []
self.objects = []
[docs] def extend(self, panes: Iterable[Any]) -> None:
"""
Extends the the tabs with a list.
Arguments
---------
objects (list): List of panel components to add as tabs.
"""
new_objects, new_names = self._to_objects_and_names(panes)
objects = list(self)
objects.extend(new_objects)
self._names.extend(new_names)
self.objects = objects
[docs] def insert(self, index: int, pane: Any) -> None:
"""
Inserts an object in the tabs at the specified index.
Arguments
---------
index (int): Index at which to insert the object.
object (object): Panel components to insert as tabs.
"""
new_object, new_name = self._to_object_and_name(pane)
new_objects = list(self.objects)
new_objects.insert(index, new_object)
self._names.insert(index, new_name)
self.objects = new_objects
[docs] def pop(self, index: int) -> Viewable:
"""
Pops an item from the tabs by index.
Arguments
---------
index (int): The index of the item to pop from the tabs.
"""
new_objects = list(self)
obj = new_objects.pop(index)
self._names.pop(index)
self.objects = new_objects
return obj
[docs] def remove(self, pane: Viewable) -> None:
"""
Removes an object from the tabs.
Arguments
---------
obj (object): The object to remove from the tabs.
"""
new_objects = list(self)
if pane in new_objects:
index = new_objects.index(pane)
new_objects.remove(pane)
self._names.pop(index)
self.objects = new_objects
[docs] def reverse(self) -> None:
"""
Reverses the tabs.
"""
new_objects = list(self)
new_objects.reverse()
self._names.reverse()
self.objects = new_objects
[docs]class ListPanel(ListLike, Panel):
"""
An abstract baseclass for Panel objects with list-like children.
"""
scroll = param.Boolean(default=False, doc="""
Whether to add scrollbars if the content overflows the size
of the container.""")
_rename: ClassVar[Mapping[str, str | None]] = {'scroll': None}
_source_transforms: ClassVar[Mapping[str, str | None]] = {'scroll': None}
__abstract = True
def __init__(self, *objects: Any, **params: Any):
from ..pane import panel
if objects:
if 'objects' in params:
raise ValueError("A %s's objects should be supplied either "
"as positional arguments or as a keyword, "
"not both." % type(self).__name__)
params['objects'] = [panel(pane) for pane in objects]
elif 'objects' in params:
objects = params['objects']
if not resolve_ref(objects) or iscoroutinefunction(objects):
params['objects'] = [panel(pane) for pane in objects]
super(Panel, self).__init__(**params)
@property
def _linked_properties(self):
return tuple(
self._property_mapping.get(p, p) for p in self.param
if p not in ListPanel.param and self._property_mapping.get(p, p) is not None
)
def _process_param_change(self, params: Dict[str, Any]) -> Dict[str, Any]:
if 'scroll' in params:
scroll = params['scroll']
css_classes = params.get('css_classes', self.css_classes)
if scroll:
if self._direction is not None:
css_classes += [f'scrollable-{self._direction}']
else:
css_classes += ['scrollable']
params['css_classes'] = css_classes
return super()._process_param_change(params)
def _cleanup(self, root: Model | None = None) -> None:
if root is not None and root.ref['id'] in state._fake_roots:
state._fake_roots.remove(root.ref['id'])
super()._cleanup(root)
for p in self.objects:
p._cleanup(root)
[docs]class NamedListPanel(NamedListLike, Panel):
active = param.Integer(default=0, bounds=(0, None), doc="""
Index of the currently displayed objects.""")
scroll = param.Boolean(default=False, doc="""
Whether to add scrollbars if the content overflows the size
of the container.""")
_rename: ClassVar[Mapping[str, str | None]] = {'scroll': None}
_source_transforms: ClassVar[Mapping[str, str | None]] = {'scroll': None}
__abstract = True
def _process_param_change(self, params: Dict[str, Any]) -> Dict[str, Any]:
if 'scroll' in params:
scroll = params['scroll']
css_classes = params.get('css_classes', self.css_classes)
if scroll:
if self._direction is not None:
css_classes += [f'scrollable-{self._direction}']
else:
css_classes += ['scrollable']
params['css_classes'] = css_classes
return super()._process_param_change(params)
def _cleanup(self, root: Model | None = None) -> None:
if root is not None and root.ref['id'] in state._fake_roots:
state._fake_roots.remove(root.ref['id'])
super()._cleanup(root)
for p in self.objects:
p._cleanup(root)
[docs]class Row(ListPanel):
"""
The `Row` layout allows arranging multiple panel objects in a horizontal
container.
It has a list-like API with methods to `append`, `extend`, `clear`,
`insert`, `pop`, `remove` and `__setitem__`, which makes it possible to
interactively update and modify the layout.
Reference: https://panel.holoviz.org/reference/layouts/Row.html
:Example:
>>> pn.Row(some_widget, some_pane, some_python_object)
"""
_bokeh_model: ClassVar[Type[Model]] = BkRow
_direction = 'horizontal'
_stylesheets: ClassVar[list[str]] = [f'{CDN_DIST}css/listpanel.css']
[docs]class Column(ListPanel):
"""
The `Column` layout allows arranging multiple panel objects in a vertical
container.
It has a list-like API with methods to `append`, `extend`, `clear`,
`insert`, `pop`, `remove` and `__setitem__`, which makes it possible to
interactively update and modify the layout.
Reference: https://panel.holoviz.org/reference/layouts/Column.html
:Example:
>>> pn.Column(some_widget, some_pane, some_python_object)
"""
scroll_position = param.Integer(default=None, doc="""
Current scroll position of the Column. Setting this value
will update the scroll position of the Column. Setting to
0 will scroll to the top.""")
auto_scroll_limit = param.Integer(bounds=(0, None), doc="""
Max pixel distance from the latest object in the Column to
activate automatic scrolling upon update. Setting to 0
disables auto-scrolling.""")
scroll_button_threshold = param.Integer(bounds=(0, None), doc="""
Min pixel distance from the latest object in the Column to
display the scroll button. Setting to 0
disables the scroll button.""")
view_latest = param.Boolean(default=False, doc="""
Whether to scroll to the latest object on init. If not
enabled the view will be on the first object.""")
_bokeh_model: ClassVar[Type[Model]] = PnColumn
_direction = 'vertical'
_stylesheets: ClassVar[list[str]] = [f'{CDN_DIST}css/listpanel.css']
@param.depends(
"scroll_position",
"auto_scroll_limit",
"scroll_button_threshold",
"view_latest",
watch=True,
on_init=True
)
def _set_scrollable(self):
self.scroll = (
self.scroll or
bool(self.scroll_position) or
bool(self.auto_scroll_limit) or
bool(self.scroll_button_threshold) or
self.view_latest
)