Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add pn.Feed to allow buffering of feed objects #6031

Merged
merged 38 commits into from
Feb 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
07a47f4
Add logs plumbing
ahuang11 Dec 12, 2023
068a09b
Rename to Log
ahuang11 Dec 12, 2023
f97efc4
Implement in column
ahuang11 Dec 12, 2023
2e20fa5
Add to index.ts
ahuang11 Dec 12, 2023
bee0431
Implement functionality
ahuang11 Dec 13, 2023
6ba4f96
Replace min_entries with loaded_entries and make 2 spaces
ahuang11 Dec 13, 2023
5832cad
use get_objects implementation
ahuang11 Dec 20, 2023
2748f9d
Cleanup
ahuang11 Dec 27, 2023
95aebb6
Add test
ahuang11 Dec 27, 2023
5a64205
Add example and fix tests
ahuang11 Dec 27, 2023
6474895
Rewrite Log implementation
philippjfr Jan 17, 2024
ceed53e
Do not reset visible_objects
philippjfr Jan 17, 2024
cfe841d
Load only after half the buffer is visible
philippjfr Jan 17, 2024
9fab290
Try to send event
ahuang11 Jan 18, 2024
109d70e
Move log to its own modules and add custom support to scroll button
ahuang11 Jan 18, 2024
a8753f4
Use super method
ahuang11 Jan 18, 2024
9ddce39
Remove loaded_entries, tweak docs, and rename visible_objects
ahuang11 Jan 18, 2024
a65e6a6
Optimize and improve behavior
ahuang11 Jan 19, 2024
83b0980
Update ref gallery
ahuang11 Jan 19, 2024
4bec589
Rename entries
ahuang11 Jan 19, 2024
956ffca
Add tests
ahuang11 Jan 19, 2024
008f0bf
Add test and watch load_buffer
ahuang11 Jan 19, 2024
1250096
Add more tests
ahuang11 Jan 19, 2024
a278079
Rename visible_indices to visible_range and visible_objects to visibl…
ahuang11 Jan 23, 2024
7f0c29d
Also cleanup synced indices to range
ahuang11 Jan 23, 2024
e8cdd8f
Rename to feed
ahuang11 Jan 23, 2024
81c9361
Missing
ahuang11 Jan 23, 2024
e27d213
Another missing
ahuang11 Jan 23, 2024
ea99984
ANother missing
ahuang11 Jan 23, 2024
71bc80f
Tests pass
ahuang11 Jan 23, 2024
6fc2a71
Couple more log mentions
ahuang11 Jan 23, 2024
2448926
Plus 1 to make it center on the last
ahuang11 Jan 23, 2024
22d55ef
Merge branch 'main' into add_logs
ahuang11 Jan 23, 2024
7b62dd5
Fix test maybe?
ahuang11 Jan 30, 2024
9f33c83
Maybe closer to fixing tests? It passes locally so I have to debug on GH
ahuang11 Jan 30, 2024
478c4e9
Be more tolerant on indeterministic numbers
ahuang11 Jan 30, 2024
08d09c7
Make more tolerant
ahuang11 Jan 30, 2024
01c4538
Fix being trapped
ahuang11 Feb 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions examples/reference/layouts/Column.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
"___"
]
},
Expand Down
98 changes: 98 additions & 0 deletions examples/reference/layouts/Feed.ipynb
Original file line number Diff line number Diff line change
@@ -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
}
6 changes: 4 additions & 2 deletions panel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,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
Expand All @@ -77,6 +77,7 @@
"Card",
"chat",
"Column",
"Feed",
"FlexBox",
"FloatPanel",
"GridBox",
Expand All @@ -87,6 +88,7 @@
"ReactiveExpr",
"Row",
"Spacer",
"Swipe",
"Tabs",
"Template",
"VSpacer",
Expand Down
2 changes: 2 additions & 0 deletions panel/layout/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -48,6 +49,7 @@
"Card",
"Column",
"Divider",
"Feed",
"FloatPanel",
"FlexBox",
"GridBox",
Expand Down
174 changes: 174 additions & 0 deletions panel/layout/feed.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions panel/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions panel/models/column.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
ahuang11 marked this conversation as resolved.
Show resolved Hide resolved
});
}

Expand Down
18 changes: 18 additions & 0 deletions panel/models/feed.py
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading