diff --git a/src/panel_material_ui/chat/ChatMessage.jsx b/src/panel_material_ui/chat/ChatMessage.jsx index 8181fd29..c09e2a98 100644 --- a/src/panel_material_ui/chat/ChatMessage.jsx +++ b/src/panel_material_ui/chat/ChatMessage.jsx @@ -168,31 +168,6 @@ export function render({model, view}) { return () => feed.removeEventListener("scroll", onScroll); }, []); - React.useEffect(() => { - if (!paperRef.current) { return; } - let layoutTimer = null; - const observer = new ResizeObserver(() => { - // Debounce layout invalidation to avoid thrashing during streaming. - clearTimeout(layoutTimer); - layoutTimer = setTimeout(() => view.invalidate_layout(), 50); - // Scroll the feed to show new/expanded content after React paints, - // but only if the user hasn't manually scrolled up. - if (!userScrolledUpRef.current) { - requestAnimationFrame(() => { - const feed = scrollContainerRef.current; - if (feed) { - feed.scrollTop = feed.scrollHeight; - } - }); - } - }); - observer.observe(paperRef.current); - return () => { - observer.disconnect(); - clearTimeout(layoutTimer); - }; - }, []); - return ( {placement === "left" && avatar_component} diff --git a/src/panel_material_ui/layout/Feed.jsx b/src/panel_material_ui/layout/Feed.jsx new file mode 100644 index 00000000..89ccbde9 --- /dev/null +++ b/src/panel_material_ui/layout/Feed.jsx @@ -0,0 +1,310 @@ +import Box from "@mui/material/Box" +import {apply_flex} from "./utils" + +const FEED_BASE_SX = { + height: "100%", + width: "100%", + display: "flex", + position: "relative", +} + +export function render({model, view}) { + const [sx] = model.useState("sx") + const [scroll_button_threshold] = model.useState("scroll_button_threshold") + const [scroll_index] = model.useState("scroll_index") + const [scroll_position, setScrollPosition] = model.useState("scroll_position") + const [view_latest] = model.useState("view_latest") + const [visibleChildren, setVisibleChildren] = model.useState("visible_children") + const objects = model.get_child("objects") + const flexDirection = model.esm_constants.direction + const boxRef = React.useRef(null) + const syncingScrollRef = React.useRef(false) + const [showScrollButton, setShowScrollButton] = React.useState(false) + const wrappersRef = React.useRef(new Map()) + const visibleSetRef = React.useRef(new Set(visibleChildren || [])) + const initialLatestDoneRef = React.useRef(!view_latest) + const topAnchorRef = React.useRef(null) + const observerRef = React.useRef(null) + const observedNodesRef = React.useRef(new Map()) + + const distanceFromLatest = React.useCallback((el) => { + return el.scrollHeight - el.scrollTop - el.clientHeight + }, []) + + const updateScrollButton = React.useCallback((el) => { + if (view.model.data.scroll_button_threshold <= 0) { + setShowScrollButton(false) + return + } + setShowScrollButton(distanceFromLatest(el) >= view.model.data.scroll_button_threshold) + }, []) + + const scrollToLatest = React.useCallback((scrollLimit = null) => { + const el = boxRef.current + if (!el) { + return false + } + if (scrollLimit !== null && distanceFromLatest(el) > scrollLimit) { + return false + } + syncingScrollRef.current = true + el.scrollTo({top: el.scrollHeight, behavior: "instant"}) + setScrollPosition(Math.round(el.scrollTop)) + syncingScrollRef.current = false + updateScrollButton(el) + return true + }, []) + + const scrollToIndex = React.useCallback((index) => { + const el = boxRef.current + if (!el || index === null || index < 0) { + return + } + const child = wrappersRef.current.get(model.objects[index]?.id) + if (!child) { + return + } + const relativeTop = child.offsetTop - el.offsetTop + el.scrollTop + setScrollPosition(Math.round(relativeTop)) + }, []) + + const captureTopAnchor = React.useCallback((el) => { + const scrollTop = el.scrollTop + for (const childModel of model.objects) { + const node = wrappersRef.current.get(childModel.id) + if (!node) { + continue + } + const top = node.offsetTop - el.offsetTop + const bottom = top + node.offsetHeight + if (bottom > scrollTop + 1) { + topAnchorRef.current = {id: childModel.id, viewportOffset: top - scrollTop} + return + } + } + topAnchorRef.current = null + }, []) + + React.useEffect(() => { + const el = boxRef.current + if (!el) { + return + } + const onScroll = () => { + if (syncingScrollRef.current) { + return + } + setScrollPosition(Math.round(el.scrollTop)) + updateScrollButton(el) + captureTopAnchor(el) + } + el.addEventListener("scroll", onScroll) + updateScrollButton(el) + captureTopAnchor(el) + return () => el.removeEventListener("scroll", onScroll) + }, []) + + React.useEffect(() => { + const el = boxRef.current + if (!el || syncingScrollRef.current) { + return + } + if (Math.abs(el.scrollTop - scroll_position) <= 1) { + return + } + syncingScrollRef.current = true + el.scrollTo({top: scroll_position, behavior: "instant"}) + syncingScrollRef.current = false + updateScrollButton(el) + }, []) + + React.useEffect(() => { + scrollToIndex(scroll_index) + }, [scroll_index]) + + React.useEffect(() => { + const handler = (msg) => { + if (msg?.type === "scroll_to") { + scrollToIndex(msg.index) + } else if (msg?.type === "scroll_latest") { + scrollToLatest(msg.scroll_limit ?? null) + } + } + model.on("msg:custom", handler) + return () => model.off("msg:custom", handler) + }, []) + + React.useEffect(() => { + if (!view_latest) { + initialLatestDoneRef.current = true + return + } + if (initialLatestDoneRef.current) { + return + } + const frameId = requestAnimationFrame(() => { + scrollToLatest() + initialLatestDoneRef.current = true + const ordered = model.objects.map((m) => m.id).filter((id) => visibleSetRef.current.has(id)) + setVisibleChildren(ordered) + }) + return () => cancelAnimationFrame(frameId) + }, [view_latest]) + + React.useEffect(() => { + const root = boxRef.current + if (!root) { + return + } + const observer = new IntersectionObserver((entries) => { + let changed = false + const next = new Set(visibleSetRef.current) + for (const entry of entries) { + const id = entry.target.getAttribute("data-feed-child-id") + if (!id) { + continue + } + if (entry.isIntersecting) { + if (!next.has(id)) { + next.add(id) + changed = true + } + } else if (next.has(id)) { + next.delete(id) + changed = true + } + } + if (!changed) { + return + } + visibleSetRef.current = next + if (!initialLatestDoneRef.current) { + return + } + const ordered = model.objects.map((m) => m.id).filter((id) => next.has(id)) + setVisibleChildren(ordered) + }, {root, threshold: 0.01}) + observerRef.current = observer + + return () => { + for (const node of observedNodesRef.current.values()) { + observer.unobserve(node) + } + observedNodesRef.current.clear() + observer.disconnect() + observerRef.current = null + } + }, []) + + React.useEffect(() => { + const observer = observerRef.current + if (!observer) { + return + } + + const currentNodes = wrappersRef.current + let changedVisible = false + const nextVisible = new Set(visibleSetRef.current) + + // Unobserve nodes that are no longer active in the current window. + for (const [id, node] of observedNodesRef.current) { + const currentNode = currentNodes.get(id) + if (!currentNode || currentNode !== node) { + observer.unobserve(node) + observedNodesRef.current.delete(id) + if (nextVisible.delete(id)) { + changedVisible = true + } + } + } + + // Observe any newly mounted nodes. + for (const [id, node] of currentNodes) { + if (observedNodesRef.current.get(id) !== node) { + observer.observe(node) + observedNodesRef.current.set(id, node) + } + } + }, [objects]) + + React.useLayoutEffect(() => { + const el = boxRef.current + const anchor = topAnchorRef.current + if (!el || !anchor) { + return + } + const node = wrappersRef.current.get(anchor.id) + if (!node) { + return + } + const top = node.offsetTop - el.offsetTop + const desired = Math.round(top - anchor.viewportOffset) + if (Math.abs(el.scrollTop - desired) <= 1) { + return + } + syncingScrollRef.current = true + el.scrollTo({top: desired, behavior: "instant"}) + setScrollPosition(Math.round(el.scrollTop)) + syncingScrollRef.current = false + updateScrollButton(el) + }, [objects]) + + const boxSx = React.useMemo( + () => [FEED_BASE_SX, {flexDirection, overflowY: "auto"}, {"& > div": {maxHeight: "unset"}}, sx || {}], + [flexDirection, sx] + ) + + return ( + + {objects.map((object, index) => { + const childModel = model.objects[index] + const childId = childModel?.id ?? `${index}` + apply_flex(view.get_child_view(childModel), flexDirection) + return ( +
{ + if (node) { + wrappersRef.current.set(childId, node) + } else { + wrappersRef.current.delete(childId) + } + }} + > + {object} +
+ ) + })} + {scroll_button_threshold > 0 && ( +
{ + scrollToLatest() + model.send_event("click", {}) + }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault() + scrollToLatest() + model.send_event("click", {}) + } + }} + style={{ + position: "sticky", + top: "auto", + bottom: "0.8rem", + alignSelf: "flex-end", + marginRight: "0.8rem", + zIndex: 1, + cursor: "pointer", + display: showScrollButton ? "inline-flex" : "none", + }} + /> + )} + + ) +} diff --git a/src/panel_material_ui/layout/base.py b/src/panel_material_ui/layout/base.py index 70f494a1..afae987b 100644 --- a/src/panel_material_ui/layout/base.py +++ b/src/panel_material_ui/layout/base.py @@ -1,15 +1,28 @@ from __future__ import annotations from collections import defaultdict -from typing import TYPE_CHECKING, Any, Callable, ClassVar, Iterable +from collections.abc import Mapping +from typing import ( + TYPE_CHECKING, + Any, + Awaitable, + Callable, + ClassVar, + Iterable, +) import param from bokeh.models import Spacer as BkSpacer from panel._param import Margin from panel.io.resources import CDN_DIST -from panel.layout.base import _SCROLL_MAPPING, ListLike, NamedListLike, SizingModeMixin +from panel.layout.base import ( + _SCROLL_MAPPING, + ListLike, + NamedListLike, + SizingModeMixin, +) from panel.pane import panel -from panel.util import param_name +from panel.util import edit_readonly, isIn, param_name from panel.viewable import Child, Children, Viewable from ..base import COLORS, MaterialComponent @@ -18,7 +31,7 @@ if TYPE_CHECKING: from bokeh.document import Document from bokeh.model import Model - from panel.viewable import Viewable + from bokeh.models.ui.ui_element import UIElement from pyviz_comms import Comm @@ -285,6 +298,12 @@ def __init__(self, *objects, **params): def _handle_click(self, event=None): self.param.trigger("scroll_button_click") + def on_click(self, callback: Callable[[param.parameterized.Event], None | Awaitable[None]]) -> param.parameterized.Watcher: + """ + Register a callback invoked when the scroll-to-latest button is clicked. + """ + return self.param.watch(callback, "scroll_button_click", onlychanged=False) + def scroll_to(self, index: int): """ Scrolls to the child at the provided index. @@ -297,6 +316,199 @@ def scroll_to(self, index: int): self._send_msg({"type": "scroll_to", "index": index}) +class Feed(Column): + """ + The `Feed` layout is a buffered `Column` optimized for long, dynamic lists. + """ + + load_buffer = param.Integer(default=10, 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.Selector( + default="y", + 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).""") + + visible_children = param.List(default=[], item_type=str, doc=""" + Internal list of currently visible frontend child model ids.""") + + visible_range = param.Range(readonly=True, doc=""" + Read-only upper and lower bounds of the currently visible feed objects. + This range is automatically updated based on scrolling.""") + + _esm_base = "Feed.jsx" + _rename: ClassVar[Mapping[str, str | None]] = { + **Column._rename, "load_buffer": None, "visible_range": None, + } + + def __init__(self, *objects, **params): + for height_param in ("height", "min_height", "max_height"): + if height_param in params: + break + else: + # Set a default height to ensure a bounded scroll viewport. + params["height"] = 300 + + super().__init__(*objects, **params) + self._last_synced: tuple[int, int] | None = None + self.param.watch(self._trigger_view_latest, "objects") + + @param.depends("visible_range", "load_buffer", watch=True) + def _trigger_get_objects(self): + if self.visible_range is None or self._last_synced is None: + return + + # 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 = ( + # Prevent being trapped with too few rendered children. + ve - vs < self.load_buffer and + ve - vs < len(self.objects) + ) + if top_trigger or bottom_trigger or invalid_trigger: + self.param.trigger("objects") + + def _trigger_view_latest(self, event): + if ( + event.type == "triggered" or not self.view_latest or + not event.new or event.new[-1] in event.old + ): + return + self.scroll_to_latest() + + @property + def _synced_range(self) -> tuple[int, int]: + 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), + ) + if self.view_latest: + return (max(n - self.load_buffer * 2, 0), n) + return (0, min(self.load_buffer, n)) + + def _process_property_change(self, msg): + if "visible_children" in msg: + visible = msg["visible_children"] + for model, _ in self._models.values(): + refs = [c.ref["id"] for c in getattr(model.data, "objects", [])] + 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._last_synced[0] if self._last_synced is not None else self._synced_range[0] + n = len(self.objects) + visible_range = [ + max(offset + indexes[0], 0), + min(offset + indexes[-1] + 1, n), + ] + if visible_range[0] >= visible_range[1]: + visible_range[0] = max(visible_range[1] - self.load_buffer, 0) + with edit_readonly(self): + self.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_child_model( + self, child: Viewable, doc: Document, root: Model, parent: Model, + comm: Comm | None + ) -> tuple[list[UIElement] | UIElement | None, list[UIElement]]: + if child is not self.objects: + return super()._get_child_model(child, doc, root, parent, comm) + + # If no previously visible objects are visible now, reset the visible range. + events = self._in_process__events.get(doc, {}) + if ( + self._last_synced and + "visible_range" not in events and + not any(isIn(obj, self.objects) for obj in child[slice(*self._last_synced)]) + ): + with edit_readonly(self): + self.visible_range = None + + from panel.pane.base import RerenderError + new_models, old_models = [], [] + self._last_synced = self._synced_range + + current_objects = list(self.objects) + ref = root.ref["id"] + for i in range(*self._last_synced): + pane = current_objects[i] + if ref in pane._models: + child, _ = pane._models[root.ref["id"]] + old_models.append(child) + else: + try: + child = pane._get_model(doc, root, parent, 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_child_model(current_objects[:i], doc, root, parent, comm) + new_models.append(child) + return new_models, old_models + + def _process_event(self, event=None) -> None: + """ + Process a scroll-button click event by forcing range to latest window. + """ + if not self.visible_range: + return + + # Need to get 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 = (min(max(n - n_visible + 1, 0), n), n) + + with param.discard_events(self): + self.load_buffer = load_buffer + + def _handle_click(self, event=None): + self._process_event() + super()._handle_click(event) + + def _handle_msg(self, msg: dict[str, Any]) -> None: + if msg.get("type") == "request_latest": + self.scroll_to_latest(scroll_limit=msg.get("scroll_limit")) + + def scroll_to_latest(self, scroll_limit: float | None = None) -> None: + """ + Scrolls the Feed to the latest entry. + """ + rerender = bool(self._last_synced and self._last_synced[-1] < len(self.objects)) + if rerender: + self._process_event() + self._send_msg({"type": "scroll_latest", "rerender": rerender, "scroll_limit": scroll_limit}) + + class Row(MaterialListLike): """ The `Row` layout arranges its contents horizontally. @@ -969,6 +1181,7 @@ class Popup(MaterialListLike): "Dialog", "Divider", "Drawer", + "Feed", "FlexBox", "Grid", "Paper", diff --git a/tests/ui/layout/test_feed.py b/tests/ui/layout/test_feed.py new file mode 100644 index 00000000..8d2ca831 --- /dev/null +++ b/tests/ui/layout/test_feed.py @@ -0,0 +1,177 @@ +import pytest + +pytest.importorskip("playwright") + +from panel.layout.spacer import Spacer +from panel.tests.util import serve_component, wait_until +from panel_material_ui.layout import Feed +from playwright.sync_api import expect + +pytestmark = pytest.mark.ui + +ITEMS = 100 + + +def _feed(page, css_class: str): + return page.locator(f".{css_class}").first + + +def _rendered_count(feed_el): + return feed_el.locator("pre").count() + + +def test_feed_load_entries(page): + feed = Feed(*list(range(ITEMS)), height=250, css_classes=["test-feed-load"]) + serve_component(page, feed) + + feed_el = _feed(page, "test-feed-load") + expect(feed_el).to_be_visible() + assert feed_el.bounding_box()["height"] == 250 + + children_count = _rendered_count(feed_el) + assert 10 <= children_count <= 40 + + feed_el.evaluate("(el) => el.scrollTo({top: 100})") + wait_until(lambda: _rendered_count(feed_el) >= 10, page) + assert 10 <= _rendered_count(feed_el) <= 40 + + feed_el.evaluate("(el) => el.scrollTo({top: 0})") + wait_until(lambda: _rendered_count(feed_el) >= 10, page) + + +def test_feed_view_latest(page): + feed = Feed(*list(range(ITEMS)), height=250, view_latest=True, css_classes=["test-feed-latest"]) + serve_component(page, feed) + + feed_el = _feed(page, "test-feed-latest") + expect(feed_el).to_be_attached() + assert feed_el.bounding_box()["height"] == 250 + + wait_until(lambda: feed_el.evaluate("(el) => el.scrollTop") > 0, page) + wait_until(lambda: int(feed_el.locator("pre").last.inner_text() or 0) > 0.9 * ITEMS, page) + + +def test_feed_scroll_to_latest(page): + feed = Feed(*list(range(ITEMS)), height=250, css_classes=["test-feed-scroll-latest"]) + serve_component(page, feed) + + feed_el = _feed(page, "test-feed-scroll-latest") + expect(feed_el).to_be_attached() + wait_until(lambda: feed_el.evaluate("(el) => el.scrollTop") == 0, page) + + feed.scroll_to_latest() + wait_until(lambda: int(feed_el.locator("pre").last.inner_text() or 0) > 0.9 * ITEMS, page) + + +def test_feed_scroll_to_latest_disabled_when_limit_zero(page): + feed = Feed(*list(range(ITEMS)), height=250, css_classes=["test-feed-limit-zero"]) + serve_component(page, feed) + + feed_el = _feed(page, "test-feed-limit-zero") + expect(feed_el).to_be_attached() + page.wait_for_timeout(200) + initial_scroll = feed_el.evaluate("(el) => el.scrollTop") + + feed.scroll_to_latest(scroll_limit=0) + page.wait_for_timeout(200) + + final_scroll = feed_el.evaluate("(el) => el.scrollTop") + assert initial_scroll == final_scroll + + +def test_feed_scroll_to_latest_always_when_limit_null(page): + feed = Feed(*list(range(ITEMS)), height=250, css_classes=["test-feed-limit-null"]) + serve_component(page, feed) + + feed_el = _feed(page, "test-feed-limit-null") + wait_until(lambda: int(feed_el.locator("pre").last.inner_text() or 0) < 0.9 * ITEMS, page) + feed.scroll_to_latest(scroll_limit=None) + wait_until(lambda: int(feed_el.locator("pre").last.inner_text() or 0) > 0.9 * ITEMS, page) + + +def test_feed_scroll_to_latest_within_limit(page): + feed = Feed( + 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), + auto_scroll_limit=0, + height=420, + css_classes=["test-feed-within-limit"], + ) + serve_component(page, feed) + + feed_el = _feed(page, "test-feed-within-limit") + expect(feed_el).to_have_js_property("scrollTop", 0) + + feed.scroll_to_latest(scroll_limit=100) + page.wait_for_timeout(200) + + feed.append(Spacer(styles=dict(background="yellow"), width=200, height=200)) + page.wait_for_timeout(200) + expect(feed_el).to_have_js_property("scrollTop", 0) + + feed_el.evaluate("(el) => el.scrollTo({top: 200})") + expect(feed_el).to_have_js_property("scrollTop", 200) + + feed.append(Spacer(styles=dict(background="yellow"), width=200, height=200)) + page.wait_for_timeout(200) + feed.scroll_to_latest(scroll_limit=1000) + + def assert_at_bottom(): + assert feed_el.evaluate("(el) => el.scrollHeight - el.scrollTop - el.clientHeight") == 0 + + wait_until(assert_at_bottom, page) + + +def test_feed_view_scroll_button(page): + feed = Feed( + *list(range(ITEMS)), + height=250, + scroll_button_threshold=50, + css_classes=["test-feed-scroll-button"], + ) + serve_component(page, feed) + + feed_el = _feed(page, "test-feed-scroll-button") + scroll_arrow = feed_el.locator(".scroll-button") + + expect(scroll_arrow).to_have_count(1) + expect(scroll_arrow).to_have_class("scroll-button visible") + expect(scroll_arrow).to_be_visible() + + scroll_arrow.click() + wait_until(lambda: feed_el.evaluate("(el) => el.scrollTop") > 0, page) + wait_until(lambda: int(feed_el.locator("pre").last.inner_text() or 0) > 50, page) + + +def test_feed_dynamic_objects(page): + feed = Feed(height=250, load_buffer=10, css_classes=["test-feed-dynamic"]) + serve_component(page, feed) + + feed.objects = list(range(ITEMS)) + feed_el = _feed(page, "test-feed-dynamic") + + wait_until(lambda: feed_el.locator("pre").first.inner_text() == "0", page) + wait_until(lambda: feed_el.locator("pre").count() >= 10, page) + + +def test_feed_reset_visible_range(page): + feed = Feed( + *list(range(ITEMS)), + load_buffer=20, + height=50, + view_latest=True, + css_classes=["test-feed-reset-range"], + ) + serve_component(page, feed) + + feed_el = _feed(page, "test-feed-reset-range") + pre = feed_el.locator("pre") + expect(pre.last).to_be_attached() + page.wait_for_timeout(500) + + wait_until(lambda: pre.last.text_content() in ("98", "99"), page) + + feed.objects = feed.objects[:20] + page.wait_for_timeout(500) + expect(pre.last).to_have_text("19")