diff --git a/README.md b/README.md index 99e402a6cf..c50497fd60 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,10 @@ Enjoying Panel? Show your support with a [Github star](https://github.com/holovi + + + + @@ -65,7 +69,7 @@ Panel works really well with the visualization tools you already know and love l Panel provides bi-directional communication making it possible to react to clicks, selections, hover etc. events. -[![Vega Selections](https://blog.holoviz.org/images/vega_selection.gif)](https://panel.holoviz.org/reference/panes/Vega.html) +[![Vega Selections](https://assets.holoviz.org/panel/readme/vega_selections.gif)](https://panel.holoviz.org/reference/panes/Vega.html) You can develop in [Jupyter Notebooks](http://jupyter.org) as well as editors like [VS Code](https://code.visualstudio.com/), [PyCharm](https://www.jetbrains.com/pycharm/) or [Spyder](https://www.spyder-ide.org/). @@ -165,7 +169,9 @@ panel serve name_of_notebook.ipynb --show [![Panel Gallery](https://assets.holoviz.org/panel/readme/gallery.jpg)](https://panel.holoviz.org/gallery/index.html) -[![Awesome Panel Gallery](https://assets.holoviz.org/panel/readme/awesome_panel.jpg)](https://www.awesome-panel.org/gallery?theme=default) +[![Panel Chat Examples](https://assets.holoviz.org/panel/readme/panel-chat-examples.jpg)](https://holoviz-topics.github.io/panel-chat-examples/) + +[![Awesome Panel Gallery](https://assets.holoviz.org/panel/readme/awesome_panel.jpg)](https://www.awesome-panel.org) ## Get started diff --git a/doc/conf.py b/doc/conf.py index 943fb2e00b..8dbf2e48a9 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -176,6 +176,10 @@ def get_requirements(): # Override the Sphinx default title that appends `documentation` html_title = f'{project} v{version}' +# Ensure the global version string includes the SHA to ensure the service +# worker is invalidated on builds between tags +if is_dev: + version = panel.__version__ # Patching GridItemCardDirective to be able to substitute the domain name # in the link option. diff --git a/doc/gallery/index.md b/doc/gallery/index.md index 8cc6b43ac8..3381ff9208 100644 --- a/doc/gallery/index.md +++ b/doc/gallery/index.md @@ -209,12 +209,12 @@ A large selection of chat applications showcasing Panels chat components. ::::{grid} 1 2 2 3 :gutter: 1 1 1 2 -:::{grid-item-card} awesome-panel.org Gallery -:img-top: https://assets.holoviz.org/panel/gallery/community/awesome_gallery.png -:link: https://awesome-panel.org/gallery +:::{grid-item-card} awesome-panel.org +:img-top: https://assets.holoviz.org/panel/gallery/community/awesome-panel.png +:link: https://awesome-panel.org :link-type: url -A great selection of applications showcasing various features of Panel built and maintained by Marc Skov Madsen. +A great selection of applications and other resources showcasing various features of Panel. ::: :::{grid-item-card} cuxfilter @@ -225,14 +225,6 @@ A great selection of applications showcasing various features of Panel built and cuxfilter ( ku-cross-filter ) is a RAPIDS framework to connect web visualizations to GPU accelerated crossfiltering which is built on top of Panel. ::: -:::{grid-item-card} awesome-panel.org Community Gallery -:img-top: https://assets.holoviz.org/panel/gallery/community/awesome_community.png -:link: https://awesome-panel.org/awesome_list -:link-type: url - -A list of applications, videos and blog posts about Panel built by the community, curated by Marc Skov Madsen. -::: - :::{grid-item-card} AstronomicalAL :img-top: https://assets.holoviz.org/panel/gallery/community/AstronomicAL.png :link: https://github.com/grant-m-s/AstronomicAL diff --git a/doc/how_to/callbacks/load.md b/doc/how_to/callbacks/load.md index b15744c199..056f131f4f 100644 --- a/doc/how_to/callbacks/load.md +++ b/doc/how_to/callbacks/load.md @@ -1,6 +1,6 @@ # Defer Long Running Tasks to Improve the User Experience -This guide addresses how to defer and orchestrate long running background tasks with `pn.state.on_load`. You can use this to improve the user experience of your app. +This guide addresses how to defer and orchestrate long running background tasks with `pn.state.onload`. You can use this to improve the user experience of your app. --- diff --git a/examples/gallery/anaconda-project.yml b/examples/gallery/anaconda-project.yml index 0b3d446e88..566b285af4 100644 --- a/examples/gallery/anaconda-project.yml +++ b/examples/gallery/anaconda-project.yml @@ -19,29 +19,30 @@ variables: PYVISTA_OFF_SCREEN: "true" packages: - - panel - - holoviews - - pandas - - scikit-learn - - scikit-image - - fastparquet - - hvplot - - datashader - - graphviz - - networkx - - ipywidgets - - pygraphviz - - plotly - - altair - - pydeck - - pyvista - - seaborn - - xgboost + - python >=3.10.13 + - panel >=1.4.0a1 + - holoviews >=1.18.2a6 + - pandas >=2.2.0 + - scikit-learn >=1.4.0 + - scikit-image >=0.22.0 + - fastparquet >=2023.10.1 + - hvplot >=0.9.2 + - datashader >=0.16.1a1 + - graphviz >=8.1.0 + - networkx >=3.2.1 + - ipywidgets >=8.1.1 + - pygraphviz >=1.11 + - plotly >=5.18.0 + - altair >=5.2.0 + - pydeck >=0.8.0 + - pyvista >=0.43.2 + - seaborn >=0.13.2 + - xgboost >=2.0.3 - vtk=9.2.6=osmesa_py310h7f736e2_103 - - pyvista - - mesalib - - numpy<1.24 - - numba::llvmlite + - pyvista >=0.43.2 + - mesalib >=24.0.0 + - numpy >=1.23.5,<1.24 + - numba::llvmlite >=0.42.0 - pip: - ipyvolume - folium diff --git a/examples/reference/layouts/Column.ipynb b/examples/reference/layouts/Column.ipynb index 2fbcb2c75c..399ea5347f 100644 --- a/examples/reference/layouts/Column.ipynb +++ b/examples/reference/layouts/Column.ipynb @@ -22,10 +22,10 @@ "\n", "* **``objects``** (list): The list of objects to display in the Column, should not generally be modified directly except when replaced in its entirety.\n", "* **``scroll``** (boolean): Enable scrollbars if the content overflows the size of the container.\n", - "* **``scroll_position``** (int): 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.\"\"\"\n", + "* **``scroll_position``** (int): 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.\n", "* **``auto_scroll_limit``** (int): Max pixel distance from the latest object in the Column to activate automatic scrolling upon update. Setting to 0 disables auto-scrolling\n", - "* **``scroll_button_threshold``** (int): Min pixel distance from the latest object in the Column to display the scroll button. Setting to 0 disables the scroll button.\"\"\"\n", - "* **``view_latest``** (bool): Whether to scroll to the latest object on init. If not enabled the view will be on the first object.\"\"\"\n", + "* **``scroll_button_threshold``** (int): Min pixel distance from the latest object in the Column to display the scroll button. Setting to 0 disables the scroll button.\n", + "* **``view_latest``** (bool): Whether to scroll to the latest object on init. If not enabled the view will be on the first object.\n", "___" ] }, diff --git a/examples/reference/layouts/Feed.ipynb b/examples/reference/layouts/Feed.ipynb new file mode 100644 index 0000000000..a922d555ae --- /dev/null +++ b/examples/reference/layouts/Feed.ipynb @@ -0,0 +1,98 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "pn.extension()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The ``Feed`` inherits from the `Column` layout, thus allows arranging multiple panel objects in a vertical container, but limits the number of objects rendered at any given moment.\n", + "\n", + "Like `Column`, it has a list-like API with methods to ``append``, ``extend``, ``clear``, ``insert``, ``pop``, ``remove`` and ``__setitem__``, which make it possible to interactively update and modify the layout.\n", + "\n", + "#### Parameters:\n", + "\n", + "For details on other options for customizing the component see the [layout](../../how_to/layout/index.md) and [styling](../../how_to/styling/index.md) how-to guides.\n", + "\n", + "* **``objects``** (list): The list of objects to display in the Feed, should not generally be modified directly except when replaced in its entirety.\n", + "* **``load_buffer``** (int): The number of objects loaded on each side of the visible objects. When scrolled halfway into the buffer, the Feed will automatically load additional objects while unloading objects on the opposite side.\n", + "* **``scroll``** (boolean): Enable scrollbars if the content overflows the size of the container.\n", + "* **``scroll_position``** (int): Current scroll position of the Feed. Setting this value will update the scroll position of the Column. Setting to 0 will scroll to the top.\n", + "* **``auto_scroll_limit``** (int): Max pixel distance from the latest object in the Feed to activate automatic scrolling upon update. Setting to 0 disables auto-scrolling\n", + "* **``scroll_button_threshold``** (int): Min pixel distance from the latest object in the Feed to display the scroll button. Setting to 0 disables the scroll button.\n", + "* **``view_latest``** (bool): Whether to scroll to the latest object on init. If not enabled the view will be on the first object.\n", + "* **``visible_range``** (list): Read-only upper and lower bounds of the currently visible Feed objects. This list is automatically updated based on scrolling.\n", + "___" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "`Feed` is a `Column-like` layout that displays a Feed of objects. It is useful for displaying long outputs with many rows because of its ability to limit the number of entries loaded at once.\n", + "\n", + "When scrolled halfway into the `load_buffer`, the Feed will automatically load additional entries while unloading entries on the opposite side." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "Feed = pn.Feed(*list(range(1000)), load_buffer=20)\n", + "Feed" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To have the Feeds immediately initialized at the latest entry, set `view_latest=True`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "Feed = pn.Feed(*list(range(1000)), view_latest=True)\n", + "Feed" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Additionally, to allow users to scroll to the bottom interactively, set a `scroll_button_threshold` which will make the Feed display a clickable scroll button." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "Feed = pn.Feed(*list(range(1000)), scroll_button_threshold=20, width=300)\n", + "Feed" + ] + } + ], + "metadata": { + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/panel/__init__.py b/panel/__init__.py index 7505e77229..e7525c952c 100644 --- a/panel/__init__.py +++ b/panel/__init__.py @@ -54,6 +54,7 @@ from . import param # noqa from . import pipeline # noqa from . import reactive # noqa +from . import template # noqa from . import viewable # noqa from . import widgets # noqa from .config import __version__, config, panel_extension as extension # noqa @@ -63,8 +64,8 @@ _jupyter_server_extension_paths, cache, ipywidget, serve, state, ) from .layout import ( # noqa - Accordion, Card, Column, FlexBox, FloatPanel, GridBox, GridSpec, GridStack, - HSpacer, Row, Spacer, Swipe, Tabs, VSpacer, WidgetBox, + Accordion, Card, Column, Feed, FlexBox, FloatPanel, GridBox, GridSpec, + GridStack, HSpacer, Row, Spacer, Swipe, Tabs, VSpacer, WidgetBox, ) from .pane import panel # noqa from .param import Param, ReactiveExpr # noqa @@ -77,6 +78,7 @@ "Card", "chat", "Column", + "Feed", "FlexBox", "FloatPanel", "GridBox", @@ -87,6 +89,7 @@ "ReactiveExpr", "Row", "Spacer", + "Swipe", "Tabs", "Template", "VSpacer", @@ -108,6 +111,7 @@ "rx", "serve", "state", + "template", "viewable", "widgets", "widget" diff --git a/panel/chat/feed.py b/panel/chat/feed.py index d7301cb8a8..c409f10fe5 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -231,8 +231,8 @@ def __init__(self, *objects, **params): # forward message params to ChatMessage for convenience message_params = params.get("message_params", {}) - for param_key in list(params.keys()): - if param_key not in ChatFeed.param and param_key in ChatMessage.param: + for param_key in params.copy(): + if param_key not in self.param and param_key in ChatMessage.param: message_params[param_key] = params.pop(param_key) params["message_params"] = message_params diff --git a/panel/chat/interface.py b/panel/chat/interface.py index 097758ca72..b9f3b6f66c 100644 --- a/panel/chat/interface.py +++ b/panel/chat/interface.py @@ -597,3 +597,78 @@ async def _cleanup_response(self): """ await super()._cleanup_response() await self._update_input_disabled() + + + def send( + self, + value: ChatMessage | dict | Any, + user: str | None = None, + avatar: str | bytes | BytesIO | None = None, + respond: bool = True, + ) -> ChatMessage | None: + """ + Sends a value and creates a new message in the chat log. + + If `respond` is `True`, additionally executes the callback, if provided. + + Arguments + --------- + value : ChatMessage | dict | Any + The message contents to send. + user : str | None + The user to send as; overrides the message message's user if provided. + Will default to the user parameter. + avatar : str | bytes | BytesIO | None + The avatar to use; overrides the message message's avatar if provided. + Will default to the avatar parameter. + respond : bool + Whether to execute the callback. + + Returns + ------- + The message that was created. + """ + if not isinstance(value, ChatMessage): + if user is None: + user = self.user + if avatar is None: + avatar = self.avatar + return super().send(value, user=user, avatar=avatar, respond=respond) + + def stream( + self, + value: str, + user: str | None = None, + avatar: str | bytes | BytesIO | None = None, + message: ChatMessage | None = None, + replace: bool = False, + ) -> ChatMessage | None: + """ + Streams a token and updates the provided message, if provided. + Otherwise creates a new message in the chat log, so be sure the + returned message is passed back into the method, e.g. + `message = chat.stream(token, message=message)`. + + This method is primarily for outputs that are not generators-- + notably LangChain. For most cases, use the send method instead. + + Arguments + --------- + value : str | dict | ChatMessage + The new token value to stream. + user : str | None + The user to stream as; overrides the message's user if provided. + Will default to the user parameter. + avatar : str | bytes | BytesIO | None + The avatar to use; overrides the message's avatar if provided. + Will default to the avatar parameter. + message : ChatMessage | None + The message to update. + replace : bool + Whether to replace the existing text when streaming a string or dict. + + Returns + ------- + The message that was updated. + """ + return super().stream(value, user=user or self.user, avatar=avatar or self.avatar, message=message, replace=replace) diff --git a/panel/chat/message.py b/panel/chat/message.py index bd0457b74e..599560c079 100644 --- a/panel/chat/message.py +++ b/panel/chat/message.py @@ -14,7 +14,8 @@ from tempfile import NamedTemporaryFile from textwrap import indent from typing import ( - TYPE_CHECKING, Any, ClassVar, Dict, Iterable, List, Union, + TYPE_CHECKING, Any, Callable, ClassVar, Dict, Iterable, List, Optional, + Union, ) from zoneinfo import ZoneInfo @@ -687,6 +688,11 @@ def update( updates["object"] = value self.param.update(**updates) + def select( + self, selector: Optional[type | Callable[[Viewable], bool]] = None + ) -> List[Viewable]: + return super().select(selector) + self._composite.select(selector) + def serialize( self, prefix_with_viewable_label: bool = True, diff --git a/panel/dist/css/card.css b/panel/dist/css/card.css index 87d87c865e..ca303667ad 100644 --- a/panel/dist/css/card.css +++ b/panel/dist/css/card.css @@ -1,6 +1,7 @@ :host(.card) { - outline: 1px solid rgba(0, 0, 0, 0.125); border-radius: 0.25rem; + flex: auto; + outline: 1px solid rgba(0, 0, 0, 0.125); } :host(.accordion) { diff --git a/panel/dist/css/listpanel.css b/panel/dist/css/listpanel.css index c076aa375d..af76ff90ec 100644 --- a/panel/dist/css/listpanel.css +++ b/panel/dist/css/listpanel.css @@ -10,6 +10,18 @@ overflow-x: auto; } +:host(.scroll) { + overflow: scroll; +} + +:host(.scroll-vertical) { + overflow-y: scroll; +} + +:host(.scroll-horizontal) { + overflow-x: scroll; +} + .scroll-button { /* For location */ position: sticky; diff --git a/panel/layout/__init__.py b/panel/layout/__init__.py index 9cc2a26135..90fc7e4b48 100644 --- a/panel/layout/__init__.py +++ b/panel/layout/__init__.py @@ -33,6 +33,7 @@ Column, ListLike, ListPanel, Panel, Row, WidgetBox, ) from .card import Card # noqa +from .feed import Feed # noqa from .flex import FlexBox # noqa from .float import FloatPanel # noqa from .grid import GridBox, GridSpec # noqa @@ -48,6 +49,7 @@ "Card", "Column", "Divider", + "Feed", "FloatPanel", "FlexBox", "GridBox", diff --git a/panel/layout/base.py b/panel/layout/base.py index 6211d33d8d..4d6be39caf 100644 --- a/panel/layout/base.py +++ b/panel/layout/base.py @@ -30,6 +30,15 @@ from ..viewable import Viewable +_SCROLL_MAPPING = { + 'both-auto': 'scrollable', + 'x-auto': 'scrollable-horizontal', + 'y-auto': 'scrollable-vertical', + 'both': 'scroll', + 'x': 'scroll-horizontal', + 'y': 'scroll-vertical', +} + _row = namedtuple("row", ["children"]) # type: ignore _col = namedtuple("col", ["children"]) # type: ignore @@ -787,9 +796,18 @@ 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.""") + scroll = param.Selector( + default=False, + objects=[False, True, "both-auto", "y-auto", "x-auto", "both", "x", "y"], + doc="""Whether to add scrollbars if the content overflows the size + of the container. If "both-auto", will only add scrollbars if + the content overflows in either directions. If "x-auto" or "y-auto", + will only add scrollbars if the content overflows in the + respective direction. If "both", will always add scrollbars. + If "x" or "y", will always add scrollbars in the respective + direction. If False, overflowing content will be clipped. + If True, will only add scrollbars in the direction of the container, + (e.g. Column: vertical, Row: horizontal).""") _rename: ClassVar[Mapping[str, str | None]] = {'scroll': None} @@ -819,15 +837,15 @@ def _linked_properties(self): ) def _process_param_change(self, params: Dict[str, Any]) -> Dict[str, Any]: - if 'scroll' in params: - scroll = params['scroll'] + if (scroll := params.get('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 + if scroll in _SCROLL_MAPPING: + scroll_class = _SCROLL_MAPPING[scroll] + elif self._direction: + scroll_class = f'scrollable-{self._direction}' + else: + scroll_class = 'scrollable' + params['css_classes'] = css_classes + [scroll_class] return super()._process_param_change(params) def _cleanup(self, root: Model | None = None) -> None: @@ -843,9 +861,18 @@ 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.""") + scroll = param.ObjectSelector( + default=False, + objects=[False, True, "both-auto", "y-auto", "x-auto", "both", "x", "y"], + doc="""Whether to add scrollbars if the content overflows the size + of the container. If "both-auto", will only add scrollbars if + the content overflows in either directions. If "x-auto" or "y-auto", + will only add scrollbars if the content overflows in the + respective direction. If "both", will always add scrollbars. + If "x" or "y", will always add scrollbars in the respective + direction. If False, overflowing content will be clipped. + If True, will only add scrollbars in the direction of the container, + (e.g. Column: vertical, Row: horizontal).""") _rename: ClassVar[Mapping[str, str | None]] = {'scroll': None} @@ -854,15 +881,15 @@ class NamedListPanel(NamedListLike, Panel): __abstract = True def _process_param_change(self, params: Dict[str, Any]) -> Dict[str, Any]: - if 'scroll' in params: - scroll = params['scroll'] + if (scroll := params.get('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 + if scroll in _SCROLL_MAPPING: + scroll_class = _SCROLL_MAPPING[scroll] + elif self._direction: + scroll_class = f'scrollable-{self._direction}' + else: + scroll_class = 'scrollable' + params['css_classes'] = css_classes + [scroll_class] return super()._process_param_change(params) def _cleanup(self, root: Model | None = None) -> None: diff --git a/panel/layout/feed.py b/panel/layout/feed.py new file mode 100644 index 0000000000..4c4321d300 --- /dev/null +++ b/panel/layout/feed.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from typing import ( + TYPE_CHECKING, ClassVar, List, Mapping, Optional, Type, +) + +import param + +from ..models import Feed as PnFeed +from ..models.feed import ScrollButtonClick +from ..util import edit_readonly +from .base import Column + +if TYPE_CHECKING: + from bokeh.document import Document + from bokeh.model import Model + from pyviz_comms import Comm + + from ..viewable import Viewable + + +class Feed(Column): + + load_buffer = param.Integer(default=50, bounds=(0, None), doc=""" + The number of objects loaded on each side of the visible objects. + When scrolled halfway into the buffer, the feed will automatically + load additional objects while unloading objects on the opposite side.""") + + scroll = param.Boolean(default=True, doc=""" + Whether to add scrollbars if the content overflows the size + of the container.""") + + visible_range = param.Range(readonly=True, doc=""" + Read-only upper and lower bounds of the currently visible feed objects. + This list is automatically updated based on scrolling.""") + + _bokeh_model: ClassVar[Type[Model]] = PnFeed + + _direction = 'vertical' + + _rename: ClassVar[Mapping[str, str | None]] = { + 'objects': 'children', 'visible_range': 'visible_children', + 'load_buffer': None, + } + + def __init__(self, *objects, **params): + for height_param in ["height", "min_height", "max_height"]: + if height_param in params: + break + else: + # sets a default height to prevent infinite load + params["height"] = 300 + + super().__init__(*objects, **params) + self._last_synced = None + + @param.depends("visible_range", "load_buffer", watch=True) + def _trigger_get_objects(self): + # visible start, end / synced start, end + vs, ve = self.visible_range + ss, se = self._last_synced + half_buffer = self.load_buffer // 2 + + top_trigger = (vs - ss) < half_buffer + bottom_trigger = (se - ve) < half_buffer + invalid_trigger = ( + # to prevent being trapped and unable to scroll + ve - vs < self.load_buffer and + ve - vs < len(self.objects) + ) + if top_trigger or bottom_trigger or invalid_trigger: + self.param.trigger("objects") + + @property + def _synced_range(self): + n = len(self.objects) + if self.visible_range: + return ( + max(self.visible_range[0] - self.load_buffer, 0), + min(self.visible_range[-1] + self.load_buffer, n) + ) + elif self.view_latest: + return (max(n - self.load_buffer * 2, 0), n) + else: + return (0, min(self.load_buffer, n)) + + def _get_model( + self, doc: Document, root: Optional[Model] = None, + parent: Optional[Model] = None, comm: Optional[Comm] = None + ) -> Model: + model = super()._get_model(doc, root, parent, comm) + self._register_events('scroll_button_click', model=model, doc=doc, comm=comm) + return model + + def _process_property_change(self, msg): + if 'visible_children' in msg: + visible = msg.pop('visible_children') + for model, _ in self._models.values(): + refs = [c.ref['id'] for c in model.children] + if visible and visible[0] in refs: + indexes = sorted(refs.index(v) for v in visible if v in refs) + break + else: + return super()._process_property_change(msg) + offset = self._synced_range[0] + n = len(self.objects) + visible_range = [ + max(offset + indexes[0], 0), + min(offset + indexes[-1], n) + ] + if visible_range[0] >= visible_range[1]: + visible_range[0] = visible_range[1] - self.load_buffer + msg['visible_range'] = tuple(visible_range) + return super()._process_property_change(msg) + + def _process_param_change(self, msg): + msg.pop('visible_range', None) + return super()._process_param_change(msg) + + def _get_objects( + self, model: Model, old_objects: List[Viewable], doc: Document, + root: Model, comm: Optional[Comm] = None + ): + from ..pane.base import RerenderError, panel + new_models, old_models = [], [] + self._last_synced = self._synced_range + + for i, pane in enumerate(self.objects): + self.objects[i] = panel(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 in range(*self._last_synced): + pane = current_objects[i] + 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 _process_event(self, event: ScrollButtonClick) -> None: + """ + Process a scroll button click event. + """ + if not self.visible_range: + return + + # need to get it all the way to the bottom rather + # than the center of the buffer zone + load_buffer = self.load_buffer + with param.discard_events(self): + self.load_buffer = 1 + + n = len(self.objects) + n_visible = self.visible_range[-1] - self.visible_range[0] + with edit_readonly(self): + # plus one to center on the last object + self.visible_range = (max(n - n_visible + 1, 0), n) + + with param.discard_events(self): + # reset the buffers and loaded objects + self.load_buffer = load_buffer diff --git a/panel/models/__init__.py b/panel/models/__init__.py index 471b7161b4..550cbf4fc1 100644 --- a/panel/models/__init__.py +++ b/panel/models/__init__.py @@ -7,6 +7,7 @@ from .browser import BrowserInfo # noqa from .datetime_picker import DatetimePicker # noqa +from .feed import Feed # noqa from .icon import ButtonIcon, ToggleIcon, _ClickableIcon # noqa from .ipywidget import IPyWidget # noqa from .layout import Card, Column # noqa diff --git a/panel/models/column.ts b/panel/models/column.ts index d47f5e8847..77adbe4f72 100644 --- a/panel/models/column.ts +++ b/panel/models/column.ts @@ -22,14 +22,14 @@ export class ColumnView extends BkColumnView { scroll_to_position(): void { requestAnimationFrame(() => { - this.el.scrollTo({top: this.model.scroll_position}); + this.el.scrollTo({ top: this.model.scroll_position, behavior: "instant"}); }); } scroll_to_latest(): void { // Waits for the child to be rendered before scrolling requestAnimationFrame(() => { - this.el.scrollTo({top: this.el.scrollHeight}); + this.model.scroll_position = Math.round(this.el.scrollHeight); }); } diff --git a/panel/models/feed.py b/panel/models/feed.py new file mode 100644 index 0000000000..85f7ee8bd2 --- /dev/null +++ b/panel/models/feed.py @@ -0,0 +1,18 @@ +from bokeh.core.properties import List, String +from bokeh.events import ModelEvent + +from .layout import Column + + +class ScrollButtonClick(ModelEvent): + + event_name = 'scroll_button_click' + + def __init__(self, model, data=None): + self.data = data + super().__init__(model=model) + + +class Feed(Column): + + visible_children = List(String()) diff --git a/panel/models/feed.ts b/panel/models/feed.ts new file mode 100644 index 0000000000..89fa1f8938 --- /dev/null +++ b/panel/models/feed.ts @@ -0,0 +1,128 @@ +import { Column, ColumnView } from "./column"; +import * as p from "@bokehjs/core/properties"; +import { build_views } from "@bokehjs/core/build_views" +import { UIElementView } from "@bokehjs/models/ui/ui_element" +import { ModelEvent } from "@bokehjs/core/bokeh_events" +import type { EventCallback } from "@bokehjs/model" + + +export class ScrollButtonClick extends ModelEvent { + static { + this.prototype.event_name = "scroll_button_click" + } +} + + +export class FeedView extends ColumnView { + model: Feed; + _intersection_observer: IntersectionObserver + _last_visible: UIElementView | null + _sync: boolean + + override initialize(): void { + super.initialize() + this._sync = true + this._intersection_observer = new IntersectionObserver((entries) => { + const visible = [...this.model.visible_children] + const nodes = this.node_map + + for (const entry of entries) { + const id = nodes.get(entry.target).id + if (entry.isIntersecting) { + if (!visible.includes(id)) { + visible.push(id) + } + } else if (visible.includes(id)) { + visible.splice(visible.indexOf(id), 1) + } + } + + if (this._sync) { + this.model.visible_children = visible + } + + if (visible.length) { + const refs = this.child_models.map((model) => model.id) + const indices = visible.map((ref) => refs.indexOf(ref)) + this._last_visible = this.child_views[Math.min(...indices)] + } else { + this._last_visible = null + } + }, { + root: this.el, + threshold: .01 + }) + } + + get node_map(): any { + const nodes = new Map() + for (const view of this.child_views) { + nodes.set(view.el, view.model) + } + return nodes + } + + async update_children(): Promise { + this._sync = false + await super.update_children() + this._sync = true + this._last_visible?.el.scrollIntoView(true) + } + + async build_child_views(): Promise { + const { created, removed } = await build_views(this._child_views, this.child_models, { parent: this }) + + const visible = this.model.visible_children + for (const view of removed) { + if (visible.includes(view.model.id)) { + visible.splice(visible.indexOf(view.model.id), 1) + } + this._resize_observer.unobserve(view.el) + this._intersection_observer.unobserve(view.el) + } + this.model.visible_children = [...visible] + + for (const view of created) { + this._resize_observer.observe(view.el, { box: "border-box" }) + this._intersection_observer.observe(view.el) + } + + return created + } + + scroll_to_latest(): void { + this.model.trigger_event(new ScrollButtonClick()) + super.scroll_to_latest() + } +} + +export namespace Feed { + export type Attrs = p.AttrsOf; + export type Props = Column.Props & { + visible_children: p.Property; + }; +} + +export interface Feed extends Feed.Attrs { } + +export class Feed extends Column { + properties: Feed.Props; + + constructor(attrs?: Partial) { + super(attrs); + } + + static __module__ = "panel.models.feed"; + + static { + this.prototype.default_view = FeedView; + + this.define(({ Array, String }) => ({ + visible_children: [Array(String), []], + })); + } + + on_click(callback: EventCallback): void { + this.on_event(ScrollButtonClick, callback) + } +} diff --git a/panel/models/html.ts b/panel/models/html.ts index 692fc3d7ca..c6b25ac9a9 100644 --- a/panel/models/html.ts +++ b/panel/models/html.ts @@ -62,7 +62,7 @@ export class HTMLView extends PanelMarkupView { } set_html(html: string | null): void { - if (html) { + if (html !== null) { this.container.innerHTML = html if (this.model.run_scripts) runScripts(this.container) diff --git a/panel/models/index.ts b/panel/models/index.ts index 6be49efd7d..6ed027d6c6 100644 --- a/panel/models/index.ts +++ b/panel/models/index.ts @@ -13,6 +13,7 @@ export {DataTabulator} from "./tabulator" export {DatetimePicker} from "./datetime_picker" export {DeckGLPlot} from "./deckgl" export {ECharts} from "./echarts" +export {Feed} from "./feed" export {FileDownload} from "./file_download" export {HTML} from "./html" export {IPyWidget} from "./ipywidget" diff --git a/panel/models/layout.py b/panel/models/layout.py index 92cb6ff06d..98cae44570 100644 --- a/panel/models/layout.py +++ b/panel/models/layout.py @@ -25,7 +25,6 @@ class Column(BkColumn): 0 will scroll to the top.""" ) - auto_scroll_limit = Int( default=0, help=""" diff --git a/panel/tests/chat/test_interface.py b/panel/tests/chat/test_interface.py index 3594f168e4..3cd717f679 100644 --- a/panel/tests/chat/test_interface.py +++ b/panel/tests/chat/test_interface.py @@ -358,6 +358,12 @@ def post_callback(instance, event): assert chat_interface.objects[1].object == "2" assert chat_interface.objects[2].object == "3" + def test_manual_user(self): + chat_interface = ChatInterface(user="New User") + assert chat_interface.user == "New User" + chat_interface.send("Test") + assert chat_interface.objects[0].user == "New User" + class TestChatInterfaceWidgetsSizingMode: def test_none(self): chat_interface = ChatInterface() diff --git a/panel/tests/layout/test_feed.py b/panel/tests/layout/test_feed.py new file mode 100644 index 0000000000..dd5e3e48ac --- /dev/null +++ b/panel/tests/layout/test_feed.py @@ -0,0 +1,13 @@ +from panel import Feed + + +def test_feed_init(document, comm): + feed = Feed() + assert feed.height == 300 + assert feed.scroll + + +def test_feed_set_objects(document, comm): + feed = Feed(height=100) + feed.objects = list(range(1000)) + assert feed.objects == list(range(1000)) diff --git a/panel/tests/ui/layout/test_column.py b/panel/tests/ui/layout/test_column.py index d955fe46b5..b746874754 100644 --- a/panel/tests/ui/layout/test_column.py +++ b/panel/tests/ui/layout/test_column.py @@ -4,7 +4,8 @@ from playwright.sync_api import expect -from panel import Column, Spacer +from panel.layout.base import _SCROLL_MAPPING, Column +from panel.layout.spacer import Spacer from panel.tests.util import serve_component, wait_until pytestmark = pytest.mark.ui @@ -27,6 +28,24 @@ def test_column_scroll(page): expect(col_el).to_have_class('bk-panel-models-layout-Column scrollable-vertical') +@pytest.mark.parametrize('scroll', _SCROLL_MAPPING.keys()) +def test_column_scroll_string(page, scroll): + col = Column( + Spacer(styles=dict(background='red'), width=200, height=200), + Spacer(styles=dict(background='green'), width=200, height=200), + Spacer(styles=dict(background='blue'), width=200, height=200), + scroll=scroll, height=420 + ) + serve_component(page, col) + + col_el = page.locator(".bk-panel-models-layout-Column") + bbox = col_el.bounding_box() + + assert bbox['width'] in (200, 215) # Ignore if browser hides empty scrollbar + assert bbox['height'] == 420 + expect(col_el).to_have_class(f'bk-panel-models-layout-Column {_SCROLL_MAPPING[scroll]}') + + def test_column_auto_scroll_limit(page): col = Column( Spacer(styles=dict(background='red'), width=200, height=200), diff --git a/panel/tests/ui/layout/test_feed.py b/panel/tests/ui/layout/test_feed.py new file mode 100644 index 0000000000..41a4e57971 --- /dev/null +++ b/panel/tests/ui/layout/test_feed.py @@ -0,0 +1,96 @@ +import pytest + +pytest.importorskip("playwright") + +from playwright.sync_api import expect + +from panel import Feed +from panel.tests.util import serve_component, wait_until + +pytestmark = pytest.mark.ui + + +def test_feed_load_entries(page): + feed = Feed(*list(range(1000)), height=250) + serve_component(page, feed) + + feed_el = page.locator(".bk-panel-models-feed-Feed") + + bbox = feed_el.bounding_box() + assert bbox["height"] == 250 + + expect(feed_el).to_have_class("bk-panel-models-feed-Feed scrollable-vertical") + + children_count = feed_el.evaluate( + '(element) => element.shadowRoot.querySelectorAll(".bk-panel-models-markup-HTML").length' + ) + assert 50 <= children_count <= 65 + + # Now scroll to somewhere down + feed_el.evaluate('(el) => el.scrollTo({top: 100})') + children_count = feed_el.evaluate( + '(element) => element.shadowRoot.querySelectorAll(".bk-panel-models-markup-HTML").length' + ) + assert 50 <= children_count <= 65 + + # Now scroll to top + feed_el.evaluate('(el) => el.scrollTo({top: 0})') + wait_until( + lambda: feed_el.evaluate( + '(element) => element.shadowRoot.querySelectorAll(".bk-panel-models-markup-HTML").length' + ) + >= 50 + ) + + +def test_feed_view_latest(page): + feed = Feed(*list(range(1000)), height=250, view_latest=True) + serve_component(page, feed) + + feed_el = page.locator(".bk-panel-models-feed-Feed") + + bbox = feed_el.bounding_box() + assert bbox["height"] == 250 + + expect(feed_el).to_have_class("bk-panel-models-feed-Feed scrollable-vertical") + + # Assert scroll is not at 0 (view_latest) + assert feed_el.evaluate('(el) => el.scrollTop') > 0 + + last_pre_element = page.query_selector_all('pre')[-1] + wait_until( + lambda: int(last_pre_element.inner_text()) > 950 + ) + + +def test_feed_view_scroll_button(page): + feed = Feed(*list(range(1000)), height=250, scroll_button_threshold=50) + serve_component(page, feed) + + feed_el = page.locator(".bk-panel-models-feed-Feed") + + # assert scroll button is visible on render + scroll_arrow = page.locator(".scroll-button") + expect(scroll_arrow).to_have_class('scroll-button visible') + expect(scroll_arrow).to_be_visible() + + # click on scroll arrow + scroll_arrow.click() + + # Assert scroll is not at 0 (view_latest) + wait_until(lambda: feed_el.evaluate('(el) => el.scrollTop') > 0) + wait_until( + lambda: int(page.query_selector_all('pre')[-1].inner_text()) > 50 + ) + + +def test_feed_dynamic_objects(page): + feed = Feed(height=250, load_buffer=10) + serve_component(page, feed) + + feed.objects = list(range(1000)) + + wait_until( + lambda: len(page.query_selector_all('pre')) > 10 + ) + assert int(page.query_selector_all('pre')[0].inner_text()) == 0 diff --git a/panel/tests/ui/pane/test_markup.py b/panel/tests/ui/pane/test_markup.py index d7364fe87a..b0a531ba7c 100644 --- a/panel/tests/ui/pane/test_markup.py +++ b/panel/tests/ui/pane/test_markup.py @@ -21,6 +21,17 @@ def test_update_markdown_pane(page): expect(page.locator(".markdown").locator("div")).to_have_text('Updated\n') +def test_update_markdown_pane_empty(page): + md = Markdown('Initial') + + serve_component(page, md) + + expect(page.locator(".markdown").locator("div")).to_have_text('Initial\n') + + md.object = '' + + expect(page.locator(".markdown").locator("div")).to_have_text('') + def test_update_markdown_height(page): md = Markdown('Initial', height=50) diff --git a/panel/util/warnings.py b/panel/util/warnings.py index 22a08d0855..9c6938b1df 100644 --- a/panel/util/warnings.py +++ b/panel/util/warnings.py @@ -40,13 +40,17 @@ def find_stack_level() -> int: frame = inspect.currentframe() stacklevel = 0 - while frame: - fname = inspect.getfile(frame) - if fname.startswith((pkg_dir, param_dir)) and not fname.startswith(test_dir): - frame = frame.f_back - stacklevel += 1 - else: - break + try: + while frame: + fname = inspect.getfile(frame) + if fname.startswith((pkg_dir, param_dir)) and not fname.startswith(test_dir): + frame = frame.f_back + stacklevel += 1 + else: + break + finally: + # See: https://docs.python.org/3/library/inspect.html#inspect.Traceback + del frame return stacklevel
Downloads
Build Status Linux/MacOS Build Status