diff --git a/examples/reference/layouts/Column.ipynb b/examples/reference/layouts/Column.ipynb index 9ab8730153..de9bdb7a4a 100644 --- a/examples/reference/layouts/Column.ipynb +++ b/examples/reference/layouts/Column.ipynb @@ -22,7 +22,9 @@ "\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", - "\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", "___" ] }, diff --git a/panel/dist/css/listpanel.css b/panel/dist/css/listpanel.css index 5f19b8cc9d..15d5c77d55 100644 --- a/panel/dist/css/listpanel.css +++ b/panel/dist/css/listpanel.css @@ -1,3 +1,46 @@ :host(.scrollable) { - overflow: scroll; + overflow: auto; +} + +:host(.scrollable-vertical) { + overflow-y: auto; +} + +:host(.scrollable-horizontal) { + overflow-x: auto; +} + +.scroll-button { + /* For location */ + position: sticky; + top: calc(100% - 38px); + left: calc(100% - 60px); + /* For icon */ + cursor: pointer; + visibility: hidden; + font-size: 18px; + border-radius: 50%; + background-color: rgba(0, 0, 0, 0.25); + color: white; + width: 36px; + min-height: 36px; + margin-bottom: -36px; /* Remove space taken */ + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + /* For animation */ + opacity: 0; + transition: + visibility 0s, + opacity 0.2s ease-in-out; +} + +.visible { + visibility: visible; + opacity: 1; +} + +.scroll-button:before { + content: '⬇'; } diff --git a/panel/layout/__init__.py b/panel/layout/__init__.py index 17464dc8ac..9cc2a26135 100644 --- a/panel/layout/__init__.py +++ b/panel/layout/__init__.py @@ -59,6 +59,7 @@ "Panel", "Row", "Spacer", + "Swipe", "Tabs", "VSpacer", "WidgetBox" diff --git a/panel/layout/base.py b/panel/layout/base.py index 60619420c4..17a68ac31d 100644 --- a/panel/layout/base.py +++ b/panel/layout/base.py @@ -12,11 +12,12 @@ import param -from bokeh.models import Column as BkColumn, Row as BkRow +from bokeh.models import Row as BkRow from ..io.model import hold from ..io.resources import CDN_DIST from ..io.state import state +from ..models import Column as PnColumn from ..reactive import Reactive from ..util import param_name, param_reprs @@ -792,11 +793,14 @@ def _linked_properties(self): ) def _process_param_change(self, params: Dict[str, Any]) -> Dict[str, Any]: - scroll = params.pop('scroll', None) - css_classes = self.css_classes or [] - if scroll: - params['css_classes'] = css_classes + ['scrollable'] - elif scroll == False: + if 'scroll' in params: + scroll = params['scroll'] + css_classes = params.get('css_classes', self.css_classes) + if scroll: + if self._direction is not None: + css_classes += [f'scrollable-{self._direction}'] + else: + css_classes += ['scrollable'] params['css_classes'] = css_classes return super()._process_param_change(params) @@ -825,10 +829,13 @@ class NamedListPanel(NamedListLike, Panel): def _process_param_change(self, params: Dict[str, Any]) -> Dict[str, Any]: if 'scroll' in params: - scroll = params.pop('scroll') - css_classes = list(self.css_classes or []) + scroll = params['scroll'] + css_classes = params.get('css_classes', self.css_classes) if scroll: - css_classes += ['scrollable'] + if self._direction is not None: + css_classes += [f'scrollable-{self._direction}'] + else: + css_classes += ['scrollable'] params['css_classes'] = css_classes return super()._process_param_change(params) @@ -879,12 +886,35 @@ class Column(ListPanel): >>> pn.Column(some_widget, some_pane, some_python_object) """ - _bokeh_model: ClassVar[Type[Model]] = BkColumn + auto_scroll_limit = param.Integer(bounds=(0, None), doc=""" + Max pixel distance from the latest object in the Column to + activate automatic scrolling upon update. Setting to 0 + disables auto-scrolling.""") + + scroll_button_threshold = param.Integer(bounds=(0, None), doc=""" + Min pixel distance from the latest object in the Column to + display the scroll button. Setting to 0 + disables the scroll button.""") + + view_latest = param.Boolean(default=False, doc=""" + Whether to scroll to the latest object on init. If not + enabled the view will be on the first object.""") + + _bokeh_model: ClassVar[Type[Model]] = PnColumn _direction = 'vertical' _stylesheets: ClassVar[list[str]] = [f'{CDN_DIST}css/listpanel.css'] + @param.depends("auto_scroll_limit", "scroll_button_threshold", "view_latest", watch=True, on_init=True) + def _set_scrollable(self): + self.scroll = ( + self.scroll or + bool(self.auto_scroll_limit) or + bool(self.scroll_button_threshold) or + self.view_latest + ) + class WidgetBox(ListPanel): """ @@ -929,7 +959,7 @@ class WidgetBox(ListPanel): @property def _bokeh_model(self) -> Type[Model]: # type: ignore - return BkRow if self.horizontal else BkColumn + return BkRow if self.horizontal else PnColumn @property def _direction(self): diff --git a/panel/layout/card.py b/panel/layout/card.py index daa459c534..2c6a020563 100644 --- a/panel/layout/card.py +++ b/panel/layout/card.py @@ -8,7 +8,7 @@ from ..io.resources import CDN_DIST from ..models import Card as BkCard -from .base import Column, ListPanel, Row +from .base import Column, Row if TYPE_CHECKING: from bokeh.model import Model @@ -95,15 +95,6 @@ def _cleanup(self, root: Model | None = None) -> None: super()._cleanup(root) self._header_layout._cleanup(root) - def _process_param_change(self, params): - scroll = params.pop('scroll', None) - css_classes = self.css_classes or [] - if scroll: - params['css_classes'] = css_classes + ['scrollable'] - elif scroll == False: - params['css_classes'] = css_classes - return super(ListPanel, self)._process_param_change(params) - def _update_header(self, *events): from ..pane import HTML, panel if self.header is None: diff --git a/panel/models/__init__.py b/panel/models/__init__.py index 2966af014e..9edea61bc2 100644 --- a/panel/models/__init__.py +++ b/panel/models/__init__.py @@ -7,7 +7,7 @@ from .datetime_picker import DatetimePicker # noqa from .ipywidget import IPyWidget # noqa -from .layout import Card # noqa +from .layout import Card, Column # noqa from .location import Location # noqa from .markup import HTML, JSON, PDF # noqa from .reactive_html import ReactiveHTML # noqa diff --git a/panel/models/card.ts b/panel/models/card.ts index a9ae5d5e71..fb2c70409f 100644 --- a/panel/models/card.ts +++ b/panel/models/card.ts @@ -1,4 +1,4 @@ -import {Column, ColumnView} from "@bokehjs/models/layouts/column" +import {Column, ColumnView} from "./column" import * as DOM from "@bokehjs/core/dom" import * as p from "@bokehjs/core/properties" diff --git a/panel/models/column.ts b/panel/models/column.ts new file mode 100644 index 0000000000..a7c6430907 --- /dev/null +++ b/panel/models/column.ts @@ -0,0 +1,112 @@ +import { Column as BkColumn, ColumnView as BkColumnView } from "@bokehjs/models/layouts/column"; +import * as DOM from "@bokehjs/core/dom" +import * as p from "@bokehjs/core/properties"; + +export class ColumnView extends BkColumnView { + model: Column; + scroll_down_arrow_el: HTMLElement; + + connect_signals(): void { + super.connect_signals(); + + const { children, scroll_button_threshold } = this.model.properties; + + this.on_change(children, () => this.trigger_auto_scroll()); + this.on_change(scroll_button_threshold, () => this.toggle_scroll_arrow()) + } + + get distance_from_latest(): number { + return this.el.scrollHeight - this.el.scrollTop - this.el.clientHeight; + } + + scroll_to_latest(): void { + // Waits for the child to be rendered before scrolling + requestAnimationFrame(() => { + this.el.scrollTop = this.el.scrollHeight; + }); + } + + trigger_auto_scroll(): void { + const limit = this.model.auto_scroll_limit + const within_limit = this.distance_from_latest <= limit + if (limit == 0 || !within_limit) + return + + this.scroll_to_latest() + } + + toggle_scroll_arrow(): void { + const threshold = this.model.scroll_button_threshold + const exceeds_threshold = this.distance_from_latest >= threshold + this.scroll_down_arrow_el.classList.toggle( + "visible", threshold !== 0 && exceeds_threshold + ) + } + + render(): void { + super.render() + this.empty() + this._update_stylesheets() + this._update_css_classes() + this._apply_styles() + this._apply_visible() + + this.class_list.add(...this.css_classes()) + this.scroll_down_arrow_el = DOM.createElement('div', { class: 'scroll-button' }); + this.shadow_el.appendChild(this.scroll_down_arrow_el); + + this.el.addEventListener("scroll", () => { + this.toggle_scroll_arrow(); + }); + this.scroll_down_arrow_el.addEventListener("click", () => { + this.scroll_to_latest(); + }); + + for (const child_view of this.child_views) { + this.shadow_el.appendChild(child_view.el) + child_view.render() + child_view.after_render() + } + } + + after_render(): void { + super.after_render() + requestAnimationFrame(() => { + if (this.model.view_latest) { + this.scroll_to_latest(); + } + this.toggle_scroll_arrow(); + }); + } +} + +export namespace Column { + export type Attrs = p.AttrsOf; + export type Props = BkColumn.Props & { + auto_scroll_limit: p.Property; + scroll_button_threshold: p.Property; + view_latest: p.Property; + }; +} + +export interface Column extends Column.Attrs { } + +export class Column extends BkColumn { + properties: Column.Props; + + constructor(attrs?: Partial) { + super(attrs); + } + + static __module__ = "panel.models.layout"; + + static { + this.prototype.default_view = ColumnView; + + this.define(({ Int, Boolean }) => ({ + auto_scroll_limit: [Int, 0], + scroll_button_threshold: [Int, 0], + view_latest: [Boolean, false], + })); + } +} diff --git a/panel/models/index.ts b/panel/models/index.ts index bc8ba021a1..5dfd7cf86b 100644 --- a/panel/models/index.ts +++ b/panel/models/index.ts @@ -2,6 +2,7 @@ export {AcePlot} from "./ace" export {Audio} from "./audio" export {BrowserInfo} from "./browser" export {Card} from "./card" +export {Column} from "./column" export {CommManager} from "./comm_manager" export {CustomSelect} from "./customselect" export {DataTabulator} from "./tabulator" diff --git a/panel/models/layout.py b/panel/models/layout.py index 14755e0ac3..b0bfd4738d 100644 --- a/panel/models/layout.py +++ b/panel/models/layout.py @@ -1,26 +1,56 @@ from bokeh.core.properties import ( - Bool, List, Nullable, String, + Bool, Int, List, Nullable, String, ) -from bokeh.models import Column +from bokeh.models import Column as BkColumn from bokeh.models.layouts import LayoutDOM __all__ = ( "Card", "HTMLBox", + "Column", ) + class HTMLBox(LayoutDOM): """ """ -class Card(Column): - active_header_background = Nullable(String, help="Background color of active Card header.") +class Column(BkColumn): + + auto_scroll_limit = Int( + default=0, + help=""" + Max pixel distance from the latest object in the Column to + activate automatic scrolling upon update. Setting to 0 + disables auto-scrolling.""") + + scroll_button_threshold = Int( + default=0, + help=""" + Min pixel distance from the latest object in the Column to + display the scroll button. Setting to 0 + disables the scroll button.""") + + view_latest = Bool( + default=False, + help=""" + Whether to scroll to the latest object on init. If not + enabled the view will be on the first object.""") + +class Card(Column): + active_header_background = Nullable( + String, help="Background color of active Card header." + ) - button_css_classes = List(String, help="CSS classes to add to the Card collapse button.") + button_css_classes = List( + String, help="CSS classes to add to the Card collapse button." + ) collapsed = Bool(True, help="Whether the Card is collapsed.") - collapsible = Bool(True, help="Whether the Card should have a button to collapse it.") + collapsible = Bool( + True, help="Whether the Card should have a button to collapse it." + ) header_background = Nullable(String, help="Background color of the Card header.") @@ -28,8 +58,8 @@ class Card(Column): header_css_classes = List(String, help="CSS classes to add to the Card header.") - header_tag = String('div', help="HTML tag to use for the Card header.") + header_tag = String("div", help="HTML tag to use for the Card header.") hide_header = Bool(False, help="Whether to hide the Card header") - tag = String('tag', help="CSS class to use for the Card as a whole.") + tag = String("tag", help="CSS class to use for the Card as a whole.") diff --git a/panel/tests/layout/test_base.py b/panel/tests/layout/test_base.py index 8aac8d133a..f37395b410 100644 --- a/panel/tests/layout/test_base.py +++ b/panel/tests/layout/test_base.py @@ -567,3 +567,14 @@ def test_no_expand_fixed(panel, document, comm): model = layout.get_root(document, comm) assert model.sizing_mode == 'fixed' + + +@pytest.mark.parametrize('scroll_param', ["auto_scroll_limit", "scroll", "scroll_button_threshold", "view_latest"]) +def test_column_scroll_params_sets_scroll(scroll_param, document, comm): + if scroll_param not in ["auto_scroll_limit", "scroll_button_threshold"]: + params = {scroll_param: True} + else: + params = {scroll_param: 1} + col = Column(**params) + assert getattr(col, scroll_param) + assert col.scroll diff --git a/panel/tests/ui/layout/test_card.py b/panel/tests/ui/layout/test_card.py index 58c7409a20..8209214bff 100644 --- a/panel/tests/ui/layout/test_card.py +++ b/panel/tests/ui/layout/test_card.py @@ -191,3 +191,11 @@ def test_card_custom_css(page, port): card_button = page.locator(f'.card-button.{additional_button_css_class}') expect(card_button).to_have_count(1) + + +def test_card_scrollable(page, port): + card = Card(scroll=True) + serve_panel_widget(page, port, card) + + card_widget = page.locator('.card') + assert 'scrollable-vertical' in card_widget.get_attribute('class') diff --git a/panel/tests/ui/layout/test_column.py b/panel/tests/ui/layout/test_column.py index 47a8202c17..6b49bed2b9 100644 --- a/panel/tests/ui/layout/test_column.py +++ b/panel/tests/ui/layout/test_column.py @@ -21,7 +21,186 @@ def test_column_scroll(page, port): page.goto(f"http://localhost:{port}") - bbox = page.locator(".bk-Column").bounding_box() + 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 + + assert 'scrollable-vertical' in col_el.get_attribute('class') + + +def test_column_auto_scroll_limit(page, port): + 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), + auto_scroll_limit=100, height=420 + ) + + serve(col, port=port, threaded=True, show=False) + + time.sleep(0.5) + + page.goto(f"http://localhost:{port}") + + column = page.locator(".bk-panel-models-layout-Column") + bbox = column.bounding_box() + + assert bbox['width'] in (200, 215) # Ignore if browser hides empty scrollbar + assert bbox['height'] == 420 + + assert 'scrollable-vertical' in column.get_attribute('class') + + scroll_loc = column.evaluate('(el) => el.scrollTop') + assert scroll_loc == 0 + + # assert scroll location is still at top + col.append(Spacer(styles=dict(background='yellow'), width=200, height=200)) + time.sleep(1) + new_scroll_loc = column.evaluate('(el) => el.scrollTop') + assert new_scroll_loc == scroll_loc + + # scroll to close to bottom + column.evaluate('(el) => el.scrollTop = el.scrollHeight') + + # assert auto scroll works; i.e. distance from bottom is 0 + col.append(Spacer(styles=dict(background='yellow'), width=200, height=200)) + time.sleep(1) + distance_from_bottom = column.evaluate( + '(el) => el.scrollHeight - el.scrollTop - el.clientHeight' + ) + assert distance_from_bottom == 0 + + +def test_column_auto_scroll_limit_disabled(page, port): + 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), + auto_scroll_limit=0, height=420, scroll=True + ) + + serve(col, port=port, threaded=True, show=False) + + time.sleep(0.5) + + page.goto(f"http://localhost:{port}") + + column = page.locator(".bk-panel-models-layout-Column") + bbox = column.bounding_box() + + assert bbox['width'] in (200, 215) # Ignore if browser hides empty scrollbar + assert bbox['height'] == 420 + + assert 'scrollable-vertical' in column.get_attribute('class') + + scroll_loc = column.evaluate('(el) => el.scrollTop') + assert scroll_loc == 0 + + # assert scroll location is still at top + col.append(Spacer(styles=dict(background='yellow'), width=200, height=200)) + time.sleep(1) + new_scroll_loc = column.evaluate('(el) => el.scrollTop') + assert new_scroll_loc == scroll_loc + + +def test_column_scroll_button_threshold(page, port): + 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_button_threshold=10, height=420 + ) + + serve(col, port=port, threaded=True, show=False) + + time.sleep(0.5) + + page.goto(f"http://localhost:{port}") + + column = page.locator(".bk-panel-models-layout-Column") + bbox = column.bounding_box() + + assert bbox['width'] in (200, 215) # Ignore if browser hides empty scrollbar + assert bbox['height'] == 420 + + assert 'scrollable-vertical' in column.get_attribute('class') + + # assert scroll button is visible on render + scroll_arrow = page.locator(".scroll-button") + assert scroll_arrow.get_attribute('class') == 'scroll-button visible' + assert scroll_arrow.is_visible() + + # assert scroll button is invisible at bottom of page + column.evaluate('(el) => el.scrollTop = el.scrollHeight') + time.sleep(0.5) + assert scroll_arrow.get_attribute('class') == 'scroll-button' + assert not scroll_arrow.is_visible() + + # assert scroll button is visible beyond threshold + column.evaluate('(el) => el.scrollTop = 5') + time.sleep(0.5) + assert scroll_arrow.get_attribute('class') == 'scroll-button visible' + assert scroll_arrow.is_visible() + + +def test_column_scroll_button_threshold_disabled(page, port): + 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=True, scroll_button_threshold=0, height=420 + ) + + serve(col, port=port, threaded=True, show=False) + + time.sleep(0.5) + + page.goto(f"http://localhost:{port}") + + column = page.locator(".bk-panel-models-layout-Column") + bbox = column.bounding_box() + + assert bbox['width'] in (200, 215) # Ignore if browser hides empty scrollbar + assert bbox['height'] == 420 + + assert 'scrollable-vertical' in column.get_attribute('class') + + # assert scroll button is invisible on render + scroll_arrow = page.locator(".scroll-button") + assert scroll_arrow.get_attribute('class') == 'scroll-button' + assert not scroll_arrow.is_visible() + + # assert scroll button is visible beyond threshold + column.evaluate('(el) => el.scrollTop = 5') + time.sleep(0.5) + assert scroll_arrow.get_attribute('class') == 'scroll-button' + assert not scroll_arrow.is_visible() + + +def test_column_view_latest(page, port): + 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), + view_latest=True, scroll=True, height=420 + ) + + serve(col, port=port, threaded=True, show=False) + + time.sleep(0.5) + + page.goto(f"http://localhost:{port}") + + column = page.locator(".bk-panel-models-layout-Column") + bbox = column.bounding_box() + + assert bbox['width'] in (200, 215) # Ignore if browser hides empty scrollbar + assert bbox['height'] == 420 + + assert 'scrollable-vertical' in column.get_attribute('class') + + # assert scroll location does not start at top + scroll_loc = column.evaluate('(el) => el.scrollTop') + assert scroll_loc != 0 diff --git a/panel/tests/ui/layout/test_row.py b/panel/tests/ui/layout/test_row.py index c16e18309f..9c12eb78ab 100644 --- a/panel/tests/ui/layout/test_row.py +++ b/panel/tests/ui/layout/test_row.py @@ -21,7 +21,10 @@ def test_row_scroll(page, port): page.goto(f"http://localhost:{port}") - bbox = page.locator(".bk-Row").bounding_box() + row_el = page.locator(".bk-Row") + bbox = row_el.bounding_box() assert bbox['width'] == 420 assert bbox['height'] in (200, 215) # Ignore if browser hides empty scrollbar + + assert 'scrollable-horizontal' in row_el.get_attribute('class') diff --git a/panel/tests/ui/layout/test_widgetbox.py b/panel/tests/ui/layout/test_widgetbox.py index 4caed1108b..6191c9a278 100644 --- a/panel/tests/ui/layout/test_widgetbox.py +++ b/panel/tests/ui/layout/test_widgetbox.py @@ -21,7 +21,7 @@ def test_widgetbox_vertical_scroll(page, port): page.goto(f"http://localhost:{port}") - bbox = page.locator(".bk-Column").bounding_box() + bbox = page.locator(".bk-panel-models-layout-Column").bounding_box() assert bbox['width'] in (202, 217) # Ignore if browser hides empty scrollbar assert bbox['height'] == 420 diff --git a/panel/widgets/chatbox.py b/panel/widgets/chatbox.py index cb3564df96..6d8d84c407 100644 --- a/panel/widgets/chatbox.py +++ b/panel/widgets/chatbox.py @@ -73,7 +73,7 @@ def __init__( ): bubble_styles = { "overflow-x": "auto", - "overflow-y": "auto", + "overflow-y": "scroll", "box-shadow": "rgba(0, 0, 0, 0.15) 1.95px 1.95px 2.6px;", "padding": "0.5em", } @@ -108,6 +108,7 @@ def __init__( align="center", margin=8, styles=bubble_styles, + sizing_mode="stretch_width", ) # create heart icon next to chat @@ -136,8 +137,9 @@ def __init__( row_objects = row_objects[::-1] container_params = dict( - sizing_mode="fixed", + styles={"overflow-y": "auto", "max-height": "300px"}, align=(horizontal_align, "center"), + sizing_mode="fixed", ) row = Row(*row_objects, **container_params) if show_name: @@ -173,6 +175,8 @@ def _serialize_obj( stylesheets = ["p { margin-block-start: 0.2em; margin-block-end: 0.2em;}"] text_styles = {"color": self._bubble_styles.get("color")} try: + if isinstance(obj, str): + panel_obj = StaticText(value=obj, stylesheets=stylesheets, styles=text_styles) if self.default_message_callable is None or issubclass( self.default_message_callable, PaneBase ): @@ -183,12 +187,11 @@ def _serialize_obj( panel_obj = self.default_message_callable(value=obj) except ValueError: panel_obj = _panel(obj, stylesheets=stylesheets, styles=text_styles) - - if panel_obj.sizing_mode is None: - panel_obj.sizing_mode = "stretch_width" + panel_obj.sizing_mode = "stretch_width" if "overflow-wrap" not in panel_obj.styles: panel_obj.styles.update({"overflow-wrap": "break-word"}) + return panel_obj def _update_like(self, event: param.parameterized.Event): @@ -281,21 +284,24 @@ def __init__(self, **params): "flex-direction": "column" if self.ascending else "column-reverse", }, ) + + sizing_mode = chat_layout.get("sizing_mode", None) + if sizing_mode == "fixed" and "height" not in chat_layout: + chat_layout["height"] = 500 + elif sizing_mode is None or sizing_mode == "stretch_width": + chat_layout["height"] = 500 + self._chat_title = StaticText( value=f"{self.name}", styles={"font-size": "1.5em"}, align="center", ) - self._chat_log = Column(**chat_layout) - self._scroll_button = Button( - name="Scroll to latest", - align="center", - sizing_mode="fixed", - width=115, - height=35, - margin=0, + self._chat_log = Column( + scroll=True, + auto_scroll=True, + scroll_button_threshold=20, + **chat_layout ) - self._add_scroll_callback(self._scroll_button, "clicks") self._current_hue = self.message_hue if self._current_hue: self._default_colors = self._generate_default_hsl(self._current_hue) @@ -304,10 +310,6 @@ def __init__(self, **params): box_objects = [self._chat_title] if self.name else [] box_objects.append(self._chat_log) - if self.ascending: - box_objects.insert(0, self._scroll_button) - else: - box_objects.append(self._scroll_button) if self.allow_input: self._attach_input(box_objects, layout) @@ -334,21 +336,6 @@ def _generate_default_hsl(self, hue: int | None, increment: int = 0) -> List[str (f"hsl({hue}, 15%, 60%)", "white"), ] - def _add_scroll_callback(self, obj: Viewable, what: str): - code = """ - const outerColumn = document.querySelector(".bk-Column") - const column = outerColumn.shadowRoot.querySelector(".bk-Column") - """ - if self.ascending: - code += "\ncolumn.scrollTop = column.scrollHeight" - else: - code += "\ncolumn.scrollTop = -column.scrollHeight" - - obj.jscallback( - args={"chat_log": self._chat_log}, - **{what: code}, - ) - def _link_disabled_loading(self, obj: Viewable): """ Link the disabled and loading attributes of the chat box to the @@ -374,7 +361,6 @@ def _attach_input(self, box_objects: List, layout: Dict[str, str]) -> None: button_type="default", sizing_mode="stretch_width", max_width=100, - height=35, ) self._send_button.on_click(self._enter_message) @@ -392,7 +378,6 @@ def _attach_input(self, box_objects: List, layout: Dict[str, str]) -> None: # the send button if isinstance(message_input, TextInput): message_input.param.watch(self._enter_message, "value") - self._add_scroll_callback(message_input, "value") send_button = self._send_button.clone() self._link_disabled_loading(send_button) message_row = Row(message_input, send_button, **row_layout)