Skip to content

Commit

Permalink
Add scroll_index to Column (#7206)
Browse files Browse the repository at this point in the history
  • Loading branch information
ahuang11 authored Dec 17, 2024
1 parent ad862e5 commit 3c20154
Show file tree
Hide file tree
Showing 7 changed files with 146 additions and 22 deletions.
3 changes: 2 additions & 1 deletion examples/reference/chat/ChatFeed.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,10 @@
"\n",
"##### Other\n",
"\n",
"* **`stop`**: Cancels the current callback task if possible.\n",
"* **`clear`**: Clears the chat log and returns the messages that were cleared.\n",
"* **`respond`**: Executes the callback with the latest message in the chat log.\n",
"* **`stop`**: Cancels the current callback task if possible.\n",
"* **`scroll_to(index: int)`**: Column will scroll to the object at the specified index.\n",
"* **`undo`**: Removes the last `count` of messages from the chat log and returns them. Default `count` is 1.\n",
"\n",
"___"
Expand Down
21 changes: 13 additions & 8 deletions examples/reference/layouts/Column.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,31 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"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 make it possible to interactively update and modify the layout.\n",
"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 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 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",
"* **``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",
"* **`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",
"* **`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",
"\n",
"#### Methods:\n",
"\n",
"* **`scroll_to(index: int)`**: Column will scroll to the object at the specified index.\n",
"\n",
"___"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"A ``Column`` layout can either be instantiated as empty and populated after the fact or using a list of objects provided as positional arguments. If the objects are not already panel components they will each be converted to one using the ``pn.panel`` conversion method."
"A `Column` layout can either be instantiated as empty and populated after the fact or using a list of objects provided as positional arguments. If the objects are not already panel components they will each be converted to one using the `pn.panel` conversion method."
]
},
{
Expand Down
25 changes: 15 additions & 10 deletions examples/reference/layouts/Feed.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,27 @@
"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",
"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",
"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",
"* **`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",
"\n",
"#### Methods:\n",
"\n",
"* **`scroll_to(index: int)`**: Column will scroll to the object at the specified index.\n",
"\n",
"___"
]
},
Expand Down
13 changes: 12 additions & 1 deletion panel/layout/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

from ..io.document import freeze_doc, hold
from ..io.resources import CDN_DIST
from ..models import Column as PnColumn
from ..models.layout import Column as PnColumn, ScrollToEvent
from ..reactive import Reactive
from ..util import param_name, param_reprs
from ..viewable import Children
Expand Down Expand Up @@ -967,6 +967,17 @@ def _set_scrollable(self):
self.view_latest
)

def scroll_to(self, index: int):
"""
Scrolls to the child at the provided index.
Arguments
---------
index: int
Index of the child object to scroll to.
"""
self._send_event(ScrollToEvent, index=index)


class WidgetBox(ListPanel):
"""
Expand Down
53 changes: 51 additions & 2 deletions panel/models/column.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {ModelEvent} from "@bokehjs/core/bokeh_events"
import {ModelEvent, server_event} from "@bokehjs/core/bokeh_events"
import {div} from "@bokehjs/core/dom"
import type * as p from "@bokehjs/core/properties"
import type {Attrs} from "@bokehjs/core/types"
import type {EventCallback} from "@bokehjs/model"
import {Column as BkColumn, ColumnView as BkColumnView} from "@bokehjs/models/layouts/column"

Expand All @@ -10,6 +11,24 @@ export class ScrollButtonClick extends ModelEvent {
}
}

@server_event("scroll_to")
export class ScrollToEvent extends ModelEvent {
constructor(readonly model: Column, readonly index: any) {
super()
this.index = index
this.origin = model
}

protected override get event_values(): Attrs {
return {model: this.origin, index: this.index}
}

static override from_values(values: object) {
const {model, index} = values as {model: Column, index: any}
return new ScrollToEvent(model, index)
}
}

export class ColumnView extends BkColumnView {
declare model: Column
_updating: boolean = false
Expand All @@ -24,12 +43,40 @@ export class ColumnView extends BkColumnView {
this.on_change(children, () => this.trigger_auto_scroll())
this.on_change(scroll_position, () => this.scroll_to_position())
this.on_change(scroll_button_threshold, () => this.toggle_scroll_button())
this.model.on_event(ScrollToEvent, (event: ScrollToEvent) => this.scroll_to_index(event.index))
}

get distance_from_latest(): number {
return this.el.scrollHeight - this.el.scrollTop - this.el.clientHeight
}

scroll_to_index(index: number): void {
if (index === null) {
return
}

if (index >= this.model.children.length) {
console.warn(`Invalid scroll index: ${index}`)
return
}

// Get the child view at the specified index
const childView = this.child_views[index]
if (!childView) {
console.warn(`Child view not found for index: ${index}`)
return
}

// Get the top position of the child element relative to the column
const childEl = childView.el
const childRect = childEl.getBoundingClientRect()
const columnRect = this.el.getBoundingClientRect()
const relativeTop = childRect.top - columnRect.top + this.el.scrollTop

// Scroll to the child's position
this.model.scroll_position = Math.round(relativeTop)
}

scroll_to_position(): void {
if (this._updating) {
return
Expand Down Expand Up @@ -106,6 +153,7 @@ export namespace Column {
export type Attrs = p.AttrsOf<Props>
export type Props = BkColumn.Props & {
scroll_position: p.Property<number>
scroll_index: p.Property<number | null>
auto_scroll_limit: p.Property<number>
scroll_button_threshold: p.Property<number>
view_latest: p.Property<boolean>
Expand All @@ -126,8 +174,9 @@ export class Column extends BkColumn {
static {
this.prototype.default_view = ColumnView

this.define<Column.Props>(({Int, Bool}) => ({
this.define<Column.Props>(({Int, Bool, Nullable}) => ({
scroll_position: [Int, 0],
scroll_index: [Nullable(Int), null],
auto_scroll_limit: [Int, 0],
scroll_button_threshold: [Int, 0],
view_latest: [Bool, false],
Expand Down
24 changes: 24 additions & 0 deletions panel/models/layout.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
from __future__ import annotations

from typing import Any

from bokeh.core.properties import (
Bool, Int, List, Nullable, String,
)
from bokeh.events import ModelEvent
from bokeh.models import Column as BkColumn
from bokeh.models.layouts import LayoutDOM

Expand All @@ -11,6 +16,18 @@
)


class ScrollToEvent(ModelEvent):

event_name = 'scroll_to'

def __init__(self, model, index=None):
self.index = index
super().__init__(model=model)

def event_values(self) -> dict[str, Any]:
return dict(super().event_values(), index=self.index)


class HTMLBox(LayoutDOM):
""" """

Expand All @@ -25,6 +42,13 @@ class Column(BkColumn):
0 will scroll to the top."""
)

scroll_index = Nullable(
Int,
help="""
Index of the object to scroll to. Setting this value will
scroll the Column to the object at the given index."""
)

auto_scroll_limit = Int(
default=0,
help="""
Expand Down
29 changes: 29 additions & 0 deletions panel/tests/ui/layout/test_column.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,32 @@ def test_column_scroll_position_param_updated(page):

column = page.locator(".bk-panel-models-layout-Column")
expect(column).to_have_js_property('scrollTop', 175)


def test_column_scroll_to(page):
col = Column(
*list(range(100)),
height=300,
sizing_mode="fixed",
scroll=True,
)

serve_component(page, col)

page.wait_for_timeout(200)

# start at 0
column_el = page.locator(".bk-panel-models-layout-Column")
expect(column_el).to_have_js_property('scrollTop', 0)

# scroll to 50
col.scroll_to(50)
expect(column_el).to_have_js_property('scrollTop', 1362)

# scroll away using mouse wheel
column_el.evaluate('(el) => el.scrollTo({top: 1000})')
expect(column_el).to_have_js_property('scrollTop', 1000)

# scroll to 50 again
col.scroll_to(50)
expect(column_el).to_have_js_property('scrollTop', 1362)

Check failure on line 267 in panel/tests/ui/layout/test_column.py

View workflow job for this annotation

GitHub Actions / ui:test-ui:macos-latest

test_column_scroll_to AssertionError: Locator expected to have JS Property '1362' Actual value: 1000 Call log: LocatorAssertions.to_have_js_property with timeout 5000ms - waiting for locator(".bk-panel-models-layout-Column") - locator resolved to <div class="bk-panel-models-layout-Column scrollable-vertical"></div> - unexpected value "1000" - locator resolved to <div class="bk-panel-models-layout-Column scrollable-vertical"></div> - unexpected value "1000" - locator resolved to <div class="bk-panel-models-layout-Column scrollable-vertical"></div> - unexpected value "1000" - locator resolved to <div class="bk-panel-models-layout-Column scrollable-vertical"></div> - unexpected value "1000" - locator resolved to <div class="bk-panel-models-layout-Column scrollable-vertical"></div> - unexpected value "1000" - locator resolved to <div class="bk-panel-models-layout-Column scrollable-vertical"></div> - unexpected value "1000" - locator resolved to <div class="bk-panel-models-layout-Column scrollable-vertical"></div> - unexpected value "1000" - locator resolved to <div class="bk-panel-models-layout-Column scrollable-vertical"></div> - unexpected value "1000" - locator resolved to <div class="bk-panel-models-layout-Column scrollable-vertical"></div> - unexpected value "1000"

0 comments on commit 3c20154

Please sign in to comment.