UNPKG

@jupyterlab/ui-components

Version:

JupyterLab - UI components written in React

1,711 lines 70.2 kB
/*
 * Copyright (c) Jupyter Development Team.
 * Distributed under the terms of the Modified BSD License.
 */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ArrayExt } from '@lumino/algorithm';
import { PromiseDelegate } from '@lumino/coreutils';
import { MessageLoop } from '@lumino/messaging';
import { Throttler } from '@lumino/polling';
import { Signal } from '@lumino/signaling';
import { PanelLayout, Widget } from '@lumino/widgets';
/**
 * For how long after the scroll request should the target position
 * be corrected to account for resize of other widgets?
 *
 * The time is given in milliseconds.
 */
const MAXIMUM_TIME_FOR_SCROLLBACK = 3000;
/**
 * How long should we wait for programmatic scroll resolution?
 * When scrolling to a not-yet-rendered item in defer/content-visibility mode,
 * MAXIMUM_TIME_WAITING_FOR_ITEM will take precedence over this value.
 *
 * The time is given in milliseconds.
 */
const PROGRAMMATIC_SCROLL_TIMEOUT = 3000;
/**
 * For how long after a scroll request should we wait for the item to show up
 * in modes which implement deferred rendering?
 * When scrolling to a not-yet-rendered item in defer/content-visibility mode,
 * this takes precedence over the PROGRAMMATIC_SCROLL_TIMEOUT value.
 *
 * The time is given in milliseconds.
 */
const MAXIMUM_TIME_WAITING_FOR_ITEM = 10000;
/*
 * Feature detection
 *
 * Ref: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
 */
let passiveIfSupported = false;
try {
    // @ts-expect-error unknown signature
    window.addEventListener('test', null, Object.defineProperty({}, 'passive', {
        get: function () {
            passiveIfSupported = { passive: true };
        }
    }));
}
catch (err) {
    // pass no-op
}
/**
 * Windowed list abstract model.
 */
export class WindowedListModel {
    /**
     * Constructor
     *
     * @param options Constructor options
     */
    constructor(options = {}) {
        var _a, _b, _c, _d, _e, _f;
        /**
         * The overlap threshold used to decide whether to scroll down to an item
         * below the viewport (smart mode). If the item overlap with the viewport
         * is greater or equal this threshold the item is considered sufficiently
         * visible and will not be scrolled to. The value is the number of pixels
         * in overlap if greater than one, or otherwise a fraction of item height.
         * By default the item is scrolled to if not full visible in the viewport.
         */
        this.scrollDownThreshold = 1;
        /**
         * The underlap threshold used to decide whether to scroll up to an item
         * above the viewport (smart mode). If the item part outside the viewport
         * (underlap) is greater than this threshold then the item is considered
         * not sufficiently visible and will be scrolled to.
         * The value is the number of pixels in underlap if greater than one, or
         * otherwise a fraction of the item height.
         * By default the item is scrolled to if not full visible in the viewport.
         */
        this.scrollUpThreshold = 0;
        /**
         * Top padding of the the outer window node.
         */
        this.paddingTop = 0;
        /**
         * Default widget size estimation
         *
         * @deprecated we always use {@link estimateWidgetSize}
         */
        this._estimatedWidgetSize = WindowedList.DEFAULT_WIDGET_SIZE;
        this._stateChanged = new Signal(this);
        this._currentWindow = [-1, -1, -1, -1];
        this._height = 0;
        this._isDisposed = false;
        this._itemsList = null;
        this._measuredAllUntilIndex = -1;
        this._overscanCount = 1;
        this._scrollOffset = 0;
        this._widgetCount = 0;
        this._widgetSizers = [];
        this._windowingActive = true;
        this._widgetCount = (_c = (_b = (_a = options.itemsList) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : options.count) !== null && _c !== void 0 ? _c : 0;
        this._overscanCount = (_d = options.overscanCount) !== null && _d !== void 0 ? _d : 1;
        this._windowingActive = (_e = options.windowingActive) !== null && _e !== void 0 ? _e : true;
        this.itemsList = (_f = options.itemsList) !== null && _f !== void 0 ? _f : null;
    }
    /**
     * List widget height
     */
    get height() {
        return this._height;
    }
    set height(h) {
        this._height = h;
    }
    /**
     * Test whether the model is disposed.
     */
    get isDisposed() {
        return this._isDisposed;
    }
    /**
     * Items list to be rendered
     */
    get itemsList() {
        return this._itemsList;
    }
    set itemsList(v) {
        var _a, _b, _c;
        if (this._itemsList !== v) {
            if (this._itemsList) {
                this._itemsList.changed.disconnect(this.onListChanged, this);
            }
            const oldValue = this._itemsList;
            this._itemsList = v;
            if (this._itemsList) {
                this._itemsList.changed.connect(this.onListChanged, this);
            }
            else {
                this._widgetCount = 0;
            }
            this._stateChanged.emit({
                name: 'list',
                newValue: this._itemsList,
                oldValue
            });
            this._stateChanged.emit({
                name: 'count',
                newValue: (_b = (_a = this._itemsList) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0,
                oldValue: (_c = oldValue === null || oldValue === void 0 ? void 0 : oldValue.length) !== null && _c !== void 0 ? _c : 0
            });
        }
    }
    /**
     * Number of widgets to render in addition to those
     * visible in the viewport.
     */
    get overscanCount() {
        return this._overscanCount;
    }
    set overscanCount(newValue) {
        if (newValue >= 1) {
            if (this._overscanCount !== newValue) {
                const oldValue = this._overscanCount;
                this._overscanCount = newValue;
                this._stateChanged.emit({ name: 'overscanCount', newValue, oldValue });
            }
        }
        else {
            console.error(`Forbidden non-positive overscan count: got ${newValue}`);
        }
    }
    /**
     * Viewport scroll offset.
     */
    get scrollOffset() {
        return this._scrollOffset;
    }
    set scrollOffset(offset) {
        this._scrollOffset = offset;
    }
    /**
     * Total number of widgets in the list
     */
    get widgetCount() {
        return this._itemsList ? this._itemsList.length : this._widgetCount;
    }
    set widgetCount(newValue) {
        if (this.itemsList) {
            console.error('It is not allow to change the widgets count of a windowed list if a items list is used.');
            return;
        }
        if (newValue >= 0) {
            if (this._widgetCount !== newValue) {
                const oldValue = this._widgetCount;
                this._widgetCount = newValue;
                this._stateChanged.emit({ name: 'count', newValue, oldValue });
            }
        }
        else {
            console.error(`Forbidden negative widget count: got ${newValue}`);
        }
    }
    /**
     * Whether windowing is active or not.
     *
     * This is true by default.
     */
    get windowingActive() {
        return this._windowingActive;
    }
    set windowingActive(newValue) {
        if (newValue !== this._windowingActive) {
            const oldValue = this._windowingActive;
            this._windowingActive = newValue;
            this._currentWindow = [-1, -1, -1, -1];
            this._measuredAllUntilIndex = -1;
            this._widgetSizers = [];
            this._stateChanged.emit({ name: 'windowingActive', newValue, oldValue });
        }
    }
    /**
     * A signal emitted when any model state changes.
     */
    get stateChanged() {
        return this._stateChanged;
    }
    /**
     * Dispose the model.
     */
    dispose() {
        if (this.isDisposed) {
            return;
        }
        this._isDisposed = true;
        Signal.clearData(this);
    }
    /**
     * Get the total list size.
     *
     * @returns Total estimated size
     */
    getEstimatedTotalSize() {
        let totalSizeOfInitialItems = 0;
        if (this._measuredAllUntilIndex >= this.widgetCount) {
            this._measuredAllUntilIndex = this.widgetCount - 1;
        }
        // These items are all measured already
        if (this._measuredAllUntilIndex >= 0) {
            const itemMetadata = this._widgetSizers[this._measuredAllUntilIndex];
            totalSizeOfInitialItems = itemMetadata.offset + itemMetadata.size;
        }
        // These items might have measurements, but some will be missing
        let totalSizeOfRemainingItems = 0;
        for (let i = this._measuredAllUntilIndex + 1; i < this.widgetCount; i++) {
            const sizer = this._widgetSizers[i];
            totalSizeOfRemainingItems += (sizer === null || sizer === void 0 ? void 0 : sizer.measured)
                ? sizer.size
                : this.estimateWidgetSize(i);
        }
        return totalSizeOfInitialItems + totalSizeOfRemainingItems;
    }
    /**
     * Get the scroll offset to display an item in the viewport.
     *
     * By default, the list will scroll as little as possible to ensure the item is fully visible (`auto`).
     * You can control the alignment of the item though by specifying a second alignment parameter.
     * Acceptable values are:
     *
     *   auto - Automatically align with the top or bottom minimising the amount scrolled,
     *          If `alignPreference` is given, follow such preferred alignment.
     *          If item is smaller than the viewport and fully visible, do not scroll at all.
     *   smart - If the item is significantly visible, don't scroll at all (regardless of whether it fits in the viewport).
     *           If the item is less than one viewport away, scroll so that it becomes fully visible (following the `auto` heuristics).
     *           If the item is more than one viewport away, scroll so that it is centered within the viewport (`center` if smaller than viewport, `top-center` otherwise).
     *   center - Align the middle of the item with the middle of the viewport (it only works well for items smaller than the viewport).
     *   top-center - Align the top of the item with the middle of the viewport (works well for items larger than the viewport).
     *   end - Align the bottom of the item to the bottom of the list.
     *   start - Align the top of item to the top of the list.
     *
     * An item is considered significantly visible if:
     *  - it overlaps with the viewport by the amount specified by `scrollDownThreshold` when below the viewport
     *  - it exceeds the viewport by the amount less than specified by `scrollUpThreshold` when above the viewport.
     *
     * @param index Item index
     * @param align Where to align the item in the viewport
     * @param margin The proportion of viewport to add when aligning with the top/bottom of the list.
     * @param precomputed Precomputed values to use when windowing is disabled.
     * @param alignPreference Allows to override the alignment of item when the `auto` heuristic decides that the item needs to be scrolled into view.
     * @returns The needed scroll offset
     */
    getOffsetForIndexAndAlignment(index, align = 'auto', margin = 0, precomputed, alignPreference) {
        const boundedMargin = Math.min(Math.max(0.0, margin), 1.0);
        const size = this._height;
        const itemMetadata = precomputed
            ? precomputed.itemMetadata
            : this._getItemMetadata(index);
        const scrollDownThreshold = this.scrollDownThreshold <= 1
            ? itemMetadata.size * this.scrollDownThreshold
            : this.scrollDownThreshold;
        const scrollUpThreshold = this.scrollUpThreshold <= 1
            ? itemMetadata.size * this.scrollUpThreshold
            : this.scrollUpThreshold;
        // When pre-computed values are not available (we are in windowing mode),
        // `getEstimatedTotalSize` is called after ItemMetadata is computed
        // to ensure it reflects actual measurements instead of just estimates.
        const estimatedTotalSize = precomputed
            ? precomputed.totalSize
            : this.getEstimatedTotalSize();
        const topOffset = Math.max(0, Math.min(estimatedTotalSize - size, itemMetadata.offset));
        const bottomOffset = Math.max(0, itemMetadata.offset - size + itemMetadata.size);
        // Current offset (+/- padding) is the top edge of the viewport.
        const currentOffset = precomputed
            ? precomputed.currentOffset
            : this._scrollOffset;
        const viewportPadding = this._windowingActive ? this.paddingTop : 0;
        const itemTop = itemMetadata.offset;
        const itemBottom = itemMetadata.offset + itemMetadata.size;
        const bottomEdge = currentOffset - viewportPadding + size;
        const topEdge = currentOffset - viewportPadding;
        const crossingBottomEdge = bottomEdge > itemTop && bottomEdge < itemBottom;
        const crossingTopEdge = topEdge > itemTop && topEdge < itemBottom;
        const isFullyWithinViewport = bottomEdge > itemBottom && topEdge < itemTop;
        if (align === 'smart') {
            const edgeLessThanOneViewportAway = currentOffset >= bottomOffset - size &&
                currentOffset <= topOffset + size;
            const visiblePartBottom = bottomEdge - itemTop;
            const hiddenPartTop = topEdge - itemTop;
            if (isFullyWithinViewport ||
                (crossingBottomEdge && visiblePartBottom >= scrollDownThreshold) ||
                (crossingTopEdge && hiddenPartTop < scrollUpThreshold)) {
                return currentOffset;
            }
            else if (edgeLessThanOneViewportAway) {
                // Possibly less than one viewport away, scroll so that it becomes visible (including the margin)
                align = 'auto';
            }
            else {
                // More than one viewport away, scroll so that it is centered within the list:
                // - if the item is smaller than viewport align the middle of the item with the middle of the viewport
                // - if the item is larger than viewport align the top of the item with the middle of the viewport
                if (itemMetadata.size > size) {
                    align = 'top-center';
                }
                else {
                    align = 'center';
                }
            }
        }
        if (align === 'auto') {
            if (isFullyWithinViewport) {
                // No need to change the position, return the current offset.
                return currentOffset;
            }
            else if (alignPreference !== undefined) {
                align = alignPreference;
            }
            else if (crossingBottomEdge || bottomEdge <= itemBottom) {
                align = 'end';
            }
            else {
                align = 'start';
            }
        }
        switch (align) {
            case 'start':
                // Align item top to the top edge.
                return Math.max(0, topOffset - boundedMargin * size) + viewportPadding;
            case 'end':
                // Align item bottom to the bottom edge.
                return bottomOffset + boundedMargin * size + viewportPadding;
            case 'center':
                // Align item centre to the middle of the viewport
                return bottomOffset + (topOffset - bottomOffset) / 2;
            case 'top-center':
                // Align item top to the middle of the viewport
                return topOffset - size / 2;
        }
    }
    /**
     * Compute the items range to display.
     *
     * It returns ``null`` if the range does not need to be updated.
     *
     * @param options - Control how the range is computed.
     *   - `virtual`: If `true`, return a virtual range without updating
     *     the current window.
     *     If `false` or omitted, update and return the actual window range.
     * @returns The current items range to display
     */
    getRangeToRender(options) {
        if (options === null || options === void 0 ? void 0 : options.virtual) {
            return this._computeVirtualRange();
        }
        let newWindowIndex = [
            0,
            Math.max(this.widgetCount - 1, -1),
            0,
            Math.max(this.widgetCount - 1, -1)
        ];
        const previousLastMeasuredIndex = this._measuredAllUntilIndex;
        if (this.windowingActive) {
            newWindowIndex = this._computeVirtualRange();
        }
        const [startIndex, stopIndex] = newWindowIndex;
        if (previousLastMeasuredIndex <= stopIndex ||
            this._currentWindow[0] !== startIndex ||
            this._currentWindow[1] !== stopIndex) {
            this._currentWindow = newWindowIndex;
            return newWindowIndex;
        }
        return newWindowIndex !== null && newWindowIndex !== void 0 ? newWindowIndex : null;
    }
    /**
     * Return the viewport top position and height for range spanning from
     * ``startIndex`` to ``stopIndex``.
     *
     * @param startIndex First item in viewport index
     * @param stopIndex Last item in viewport index
     * @returns The viewport top position and its height
     */
    getSpan(startIndex, stopIndex) {
        const startSizer = this._getItemMetadata(startIndex);
        const top = startSizer.offset;
        const stopSizer = this._getItemMetadata(stopIndex);
        const height = stopSizer.offset - startSizer.offset + stopSizer.size;
        return [top, height];
    }
    /**
     * WindowedListModel caches offsets and measurements for each index for performance purposes.
     * This method clears that cached data for all items after (and including) the specified index.
     *
     * The list will automatically re-render after the index is reset.
     *
     * @param index
     */
    resetAfterIndex(index) {
        const oldValue = this._measuredAllUntilIndex;
        this._measuredAllUntilIndex = Math.min(index, this._measuredAllUntilIndex);
        // Because `resetAfterIndex` is always called after an operation modifying
        // the list of widget sizers, we need to ensure that offsets are correct;
        // e.g. removing a cell would make the offsets of all following cells too high.
        // The simplest way to "heal" the offsets is to recompute them all.
        for (const [i, sizer] of this._widgetSizers.entries()) {
            if (i === 0) {
                continue;
            }
            const previous = this._widgetSizers[i - 1];
            sizer.offset = previous.offset + previous.size;
        }
        if (this._measuredAllUntilIndex !== oldValue) {
            this._stateChanged.emit({ name: 'index', newValue: index, oldValue });
        }
    }
    /**
     * Update item sizes.
     *
     * This should be called when the real item sizes has been
     * measured.
     *
     * @param sizes New sizes per item index
     * @returns Whether some sizes changed or not
     */
    setWidgetSize(sizes) {
        if (this._windowingActive || this._currentWindow[0] >= 0) {
            let minIndex = Infinity;
            let measuredAllItemsUntil = -1;
            let offsetDelta = 0;
            let allPreviousMeasured = true;
            const sizesMap = new Map(sizes.map(i => [i.index, i.size]));
            const highestIndex = Math.max(...sizesMap.keys());
            // add sizers at the end if needed
            const entries = [
                ...this._widgetSizers.entries()
            ];
            for (let i = this._widgetSizers.length; i <= highestIndex; i++) {
                entries.push([i, null]);
            }
            for (let [index, sizer] of entries) {
                const measuredSize = sizesMap.get(index);
                let itemDelta = 0;
                const hadSizer = !!sizer;
                if (!sizer) {
                    const previous = this._widgetSizers[index - 1];
                    const newSizer = {
                        offset: previous ? previous.offset + previous.size : 0,
                        size: measuredSize !== undefined
                            ? measuredSize
                            : this.estimateWidgetSize(index),
                        measured: measuredSize !== undefined
                    };
                    this._widgetSizers[index] = newSizer;
                    sizer = newSizer;
                }
                if (measuredSize !== undefined) {
                    // If the we learned about the size, update it and compute atomic offset for following item (`itemDelta`)
                    if (sizer.size != measuredSize) {
                        itemDelta = measuredSize - sizer.size;
                        sizer.size = measuredSize;
                        minIndex = Math.min(minIndex, index);
                    }
                    // Always set the flag in case the size estimator provides perfect result
                    sizer.measured = true;
                }
                // If all items so far have actual size measurements...
                if (allPreviousMeasured) {
                    if (sizer.measured) {
                        // and this item has a size measurement, we can say that
                        // all items until now have measurements:
                        measuredAllItemsUntil = index;
                    }
                    else {
                        allPreviousMeasured = false;
                    }
                }
                if (hadSizer && offsetDelta !== 0) {
                    // Adjust offsets for all items where it is needed
                    sizer.offset += offsetDelta;
                }
                // Keep track of the overall change in offset that will need to be applied to following items
                offsetDelta += itemDelta;
            }
            if (measuredAllItemsUntil !== -1) {
                this._measuredAllUntilIndex = measuredAllItemsUntil;
            }
            // If some sizes changed
            if (minIndex !== Infinity) {
                return true;
            }
        }
        return false;
    }
    /**
     * Callback on list changes
     *
     * @param list List items
     * @param changes List change
     */
    onListChanged(list, changes) {
        switch (changes.type) {
            case 'add':
                this._widgetSizers.splice(changes.newIndex, 0, ...new Array(changes.newValues.length).fill(undefined).map((_, i) => {
                    return { offset: 0, size: this.estimateWidgetSize(i) };
                }));
                this.resetAfterIndex(changes.newIndex - 1);
                break;
            case 'move':
                ArrayExt.move(this._widgetSizers, changes.oldIndex, changes.newIndex);
                this.resetAfterIndex(Math.min(changes.newIndex, changes.oldIndex) - 1);
                break;
            case 'remove':
                this._widgetSizers.splice(changes.oldIndex, changes.oldValues.length);
                this.resetAfterIndex(changes.oldIndex - 1);
                break;
            case 'set':
                this.resetAfterIndex(changes.newIndex - 1);
                break;
            case 'clear':
                this._widgetSizers.length = 0;
                this.resetAfterIndex(-1);
                break;
        }
    }
    _getItemMetadata(index) {
        var _a;
        if (index > this._measuredAllUntilIndex) {
            let offset = 0;
            if (this._measuredAllUntilIndex >= 0) {
                const itemMetadata = this._widgetSizers[this._measuredAllUntilIndex];
                offset = itemMetadata.offset + itemMetadata.size;
            }
            for (let i = this._measuredAllUntilIndex + 1; i <= index; i++) {
                let size;
                let measured;
                if ((_a = this._widgetSizers[i]) === null || _a === void 0 ? void 0 : _a.measured) {
                    size = this._widgetSizers[i].size;
                    measured = true;
                }
                else {
                    const widget = this.widgetRenderer(i);
                    if ((widget === null || widget === void 0 ? void 0 : widget.node) && widget.node.isConnected) {
                        size = widget.node.getBoundingClientRect().height;
                        measured = true;
                    }
                    else {
                        size = this.estimateWidgetSize(i);
                        measured = false;
                    }
                }
                this._widgetSizers[i] = { offset, size, measured };
                offset += size;
            }
            // Because the loop above updates estimated sizes,
            // we need to fix (heal) offsets of the remaining items.
            for (let i = index + 1; i < this._widgetSizers.length; i++) {
                const sizer = this._widgetSizers[i];
                const previous = this._widgetSizers[i - 1];
                sizer.offset = previous.offset + previous.size;
            }
        }
        for (let i = 0; i <= this._measuredAllUntilIndex; i++) {
            const sizer = this._widgetSizers[i];
            if (i === 0) {
                if (sizer.offset !== 0) {
                    throw new Error('First offset is not null');
                }
            }
            else {
                const previous = this._widgetSizers[i - 1];
                if (sizer.offset !== previous.offset + previous.size) {
                    throw new Error(`Sizer ${i} has incorrect offset.`);
                }
            }
        }
        return this._widgetSizers[index];
    }
    _findNearestItem(offset) {
        const lastContinouslyMeasuredItemOffset = this._measuredAllUntilIndex > 0
            ? this._widgetSizers[this._measuredAllUntilIndex].offset
            : 0;
        if (lastContinouslyMeasuredItemOffset >= offset) {
            // If we've already measured items within this range just use a binary search as it's faster.
            return this._findNearestItemBinarySearch(this._measuredAllUntilIndex, 0, offset);
        }
        else {
            // If we haven't yet measured this high, fallback to an exponential search with an inner binary search.
            // The exponential search avoids pre-computing sizes for the full set of items as a binary search would.
            // The overall complexity for this approach is O(log n).
            return this._findNearestItemExponentialSearch(Math.max(0, this._measuredAllUntilIndex), offset);
        }
    }
    _findNearestItemBinarySearch(high, low, offset) {
        while (low <= high) {
            const middle = low + Math.floor((high - low) / 2);
            const currentOffset = this._getItemMetadata(middle).offset;
            if (currentOffset === offset) {
                return middle;
            }
            else if (currentOffset < offset) {
                low = middle + 1;
            }
            else if (currentOffset > offset) {
                high = middle - 1;
            }
        }
        if (low > 0) {
            return low - 1;
        }
        else {
            return 0;
        }
    }
    _findNearestItemExponentialSearch(index, offset) {
        let interval = 1;
        while (index < this.widgetCount &&
            this._getItemMetadata(index).offset < offset) {
            index += interval;
            interval *= 2;
        }
        return this._findNearestItemBinarySearch(Math.min(index, this.widgetCount - 1), Math.floor(index / 2), offset);
    }
    /**
     * Compute the virtual range of items to render.
     */
    _computeVirtualRange() {
        const widgetCount = this.widgetCount;
        if (widgetCount === 0) {
            return [-1, -1, -1, -1];
        }
        const startIndex = this._getStartIndexForOffset(this._scrollOffset);
        const stopIndex = this._getStopIndexForStartIndex(startIndex, this._scrollOffset);
        const overscanBackward = Math.max(1, this.overscanCount);
        const overscanForward = Math.max(1, this.overscanCount);
        return [
            Math.max(0, startIndex - overscanBackward),
            Math.max(0, Math.min(widgetCount - 1, stopIndex + overscanForward)),
            startIndex,
            stopIndex
        ];
    }
    _getStartIndexForOffset(offset) {
        return this._findNearestItem(offset);
    }
    _getStopIndexForStartIndex(startIndex, scrollOffset) {
        const size = this._height;
        const itemMetadata = this._getItemMetadata(startIndex);
        const maxOffset = scrollOffset + size;
        let offset = itemMetadata.offset + itemMetadata.size;
        let stopIndex = startIndex;
        while (stopIndex < this.widgetCount - 1 && offset < maxOffset) {
            stopIndex++;
            offset += this._getItemMetadata(stopIndex).size;
        }
        return stopIndex;
    }
}
/**
 * Windowed list widget
 */
export class WindowedList extends Widget {
    /**
     * Constructor
     *
     * @param options Constructor options
     */
    constructor(options) {
        var _a, _b;
        const renderer = (_a = options.renderer) !== null && _a !== void 0 ? _a : WindowedList.defaultRenderer;
        const node = document.createElement('div');
        node.className = 'jp-WindowedPanel';
        const scrollbarElement = node.appendChild(document.createElement('div'));
        scrollbarElement.classList.add('jp-WindowedPanel-scrollbar');
        const indicator = scrollbarElement.appendChild(renderer.createScrollbarViewportIndicator
            ? renderer.createScrollbarViewportIndicator()
            : WindowedList.defaultRenderer.createScrollbarViewportIndicator());
        indicator.classList.add('jp-WindowedPanel-scrollbar-viewportIndicator');
        const list = scrollbarElement.appendChild(renderer.createScrollbar());
        list.classList.add('jp-WindowedPanel-scrollbar-content');
        const outerElement = node.appendChild(renderer.createOuter());
        outerElement.classList.add('jp-WindowedPanel-outer');
        const innerElement = outerElement.appendChild(document.createElement('div'));
        innerElement.className = 'jp-WindowedPanel-inner';
        const viewport = innerElement.appendChild(renderer.createViewport());
        viewport.classList.add('jp-WindowedPanel-viewport');
        viewport.classList.add('jp-zoom-target');
        super({ node });
        /**
         * A signal that emits the index when the virtual scrollbar jumps to an item.
         */
        this.jumped = new Signal(this);
        this._scrollbarItems = {};
        this._viewportPaddingTop = 0;
        this._viewportPaddingBottom = 0;
        this._pendingItemWaits = 0;
        this._needsUpdate = false;
        this._requiresTotalSizeUpdate = false;
        this._timerToClearScrollStatus = null;
        super.layout = (_b = options.layout) !== null && _b !== void 0 ? _b : new WindowedLayout();
        this.renderer = renderer;
        this._viewportIndicator = indicator;
        this._innerElement = innerElement;
        this._isScrolling = null;
        this._outerElement = outerElement;
        this._itemsResizeObserver = null;
        this._scrollbarElement = scrollbarElement;
        this._scrollToItem = null;
        this._scrollRepaint = null;
        this._scrollUpdateWasRequested = false;
        this._updater = new Throttler(() => this.update(), 50);
        this._viewModel = options.model;
        this._viewport = viewport;
        if (options.scrollbar) {
            node.classList.add('jp-mod-virtual-scrollbar');
        }
        this.viewModel.stateChanged.connect(this.onStateChanged, this);
    }
    /**
     * Whether the parent is hidden or not.
     *
     * This should be set externally if a container is hidden to
     * stop updating the widget size when hidden.
     */
    get isParentHidden() {
        return this._isParentHidden;
    }
    set isParentHidden(v) {
        this._isParentHidden = v;
    }
    /**
     * Widget layout
     */
    get layout() {
        return super.layout;
    }
    /**
     * The outer container of the windowed list.
     */
    get outerNode() {
        return this._outerElement;
    }
    /**
     * Viewport
     */
    get viewportNode() {
        return this._viewport;
    }
    /**
     * Flag to enable virtual scrollbar.
     */
    get scrollbar() {
        return this.node.classList.contains('jp-mod-virtual-scrollbar');
    }
    set scrollbar(enabled) {
        if (enabled) {
            this.node.classList.add('jp-mod-virtual-scrollbar');
        }
        else {
            this.node.classList.remove('jp-mod-virtual-scrollbar');
        }
        this._adjustDimensionsForScrollbar();
        this.update();
    }
    /**
     * Windowed list view model
     */
    get viewModel() {
        return this._viewModel;
    }
    /**
     * Dispose the windowed list.
     */
    dispose() {
        this._updater.dispose();
        super.dispose();
    }
    /**
     * Callback on event.
     *
     * @param event Event
     */
    handleEvent(event) {
        switch (event.type) {
            case 'pointerdown':
                this._evtPointerDown(event);
                // Stop propagation of this event; a `mousedown` event will still
                // be automatically dispatched and handled by the parent notebook
                // (which will close any open context menu, etc.)
                event.stopPropagation();
                break;
            case 'scrollend':
                this._onScrollEnd();
                break;
            case 'scroll':
                this.onScroll(event);
                break;
        }
    }
    /**
     * Scroll to the specified offset `scrollTop`.
     *
     * @param scrollOffset Offset to scroll
     *
     * @deprecated since v4 This is an internal helper. Prefer calling `scrollToItem`.
     */
    scrollTo(scrollOffset) {
        this._renderScrollbar();
        if (!this.viewModel.windowingActive) {
            this._outerElement.scrollTo({ top: scrollOffset });
            // Scrolling done with browser-native code above
            this._markProgrammaticScrollingDone();
            return;
        }
        scrollOffset = Math.max(0, scrollOffset);
        if (scrollOffset !== this.viewModel.scrollOffset) {
            this.viewModel.scrollOffset = scrollOffset;
            this._scrollUpdateWasRequested = true;
            // Scroll will happen in udpate() routine
            this.update();
        }
        else {
            // No further scrolling will happen
            this._markProgrammaticScrollingDone();
        }
    }
    /**
     * Scroll to the specified item.
     *
     * By default, the list will scroll as little as possible to ensure the item is fully visible (`auto`).
     * You can control the alignment of the item though by specifying a second alignment parameter.
     * Acceptable values are:
     *
     *   auto - Automatically align with the top or bottom minimising the amount scrolled,
     *          If `alignPreference` is given, follow such preferred alignment.
     *          If item is smaller than the viewport and fully visible, do not scroll at all.
     *   smart - If the item is significantly visible, don't scroll at all (regardless of whether it fits in the viewport).
     *           If the item is less than one viewport away, scroll so that it becomes fully visible (following the `auto` heuristics).
     *           If the item is more than one viewport away, scroll so that it is centered within the viewport (`center` if smaller than viewport, `top-center` otherwise).
     *   center - Align the middle of the item with the middle of the viewport (it only works well for items smaller than the viewport).
     *   top-center - Align the top of the item with the middle of the viewport (works well for items larger than the viewport).
     *   end - Align the bottom of the item to the bottom of the list.
     *   start - Align the top of item to the top of the list.
     *
     * @param index Item index to scroll to
     * @param align Type of alignment
     * @param margin In 'smart' mode the viewport proportion to add
     * @param alignPreference Allows to override the alignment of item when the `auto` heuristic decides that the item needs to be scrolled into view.
     */
    scrollToItem(index, align = 'auto', margin = 0.25, alignPreference) {
        let delegate;
        if (!this._isScrolling ||
            this._scrollToItem === null ||
            this._scrollToItem[0] !== index ||
            this._scrollToItem[1] !== align) {
            if (this._isScrolling) {
                this._isScrolling.reject('Scrolling to a new item is requested.');
            }
            delegate = new PromiseDelegate();
            this._isScrolling = delegate;
            // Catch the internal reject, as otherwise this will
            // result in an unhandled promise rejection in test.
            delegate.promise.catch(console.debug);
        }
        else {
            delegate = this._isScrolling;
        }
        this._scrollToItem = [index, align, margin, alignPreference];
        this._resetScrollToItem();
        if (!this.viewModel.windowingActive) {
            // The requested item may not be attached to the DOM yet.
            // Wait for it to be inserted before computing its position.
            // While we wait, the scroll has not happened yet, so the
            // `_resetScrollToItem` safety timer must not resolve `delegate` (it
            // would mark the programmatic scroll done before it actually occurred).
            // We track the number of outstanding waits rather than a boolean so this
            // stays correct when several scroll requests overlap: each request that
            // starts waiting is balanced by exactly one decrement in `finally`, and
            // the timer only resolves once no wait is left.
            const count = this.viewModel.widgetCount;
            if (count <= 0) {
                this._markProgrammaticScrollingDone();
                return delegate.promise;
            }
            const clampedIndex = Math.max(0, Math.min(index, count - 1));
            this._pendingItemWaits++;
            void this._waitForItem(clampedIndex)
                .then(item => {
                // Bail if a newer scroll request superseded this one while waiting;
                // the surviving request owns the scroll. (`_pendingItemWaits` keeps
                // the safety timer from resolving `delegate` until every wait -
                // including this superseded one - has settled).
                if (this._isScrolling !== delegate) {
                    return;
                }
                if (!item) {
                    // Note: this can happen if the item never gets rendered.
                    console.debug(`Element with index ${clampedIndex} not found`);
                    this._markProgrammaticScrollingDone();
                    return;
                }
                this.scrollTo(this.viewModel.getOffsetForIndexAndAlignment(clampedIndex, align, margin, {
                    totalSize: this._outerElement.scrollHeight,
                    itemMetadata: {
                        offset: item.offsetTop,
                        size: item.clientHeight
                    },
                    currentOffset: this._outerElement.scrollTop
                }, alignPreference));
            })
                .catch(error => {
                // If computing the offset or scrolling threw, resolve the delegate
                // so callers awaiting `scrollToItem` are not left hung (the safety
                // timer is no longer guaranteed to be pending), and surface the
                // error for debugging.
                console.warn(error);
                this._markProgrammaticScrollingDone();
            })
                .finally(() => {
                // Balances the increment above. Runs exactly once regardless of
                // whether the wait scrolled, bailed, or threw.
                this._pendingItemWaits--;
            });
            return delegate.promise;
        }
        this.scrollTo(this.viewModel.getOffsetForIndexAndAlignment(Math.max(0, Math.min(index, this.viewModel.widgetCount - 1)), align, margin, undefined, alignPreference));
        return delegate.promise;
    }
    /**
     * Wait for the item with the given index to be attached to the DOM.
     * This resolves with the matching element once it
     * is inserted, or with `null` if it does not appear
     * within the timeout.
     *
     * @param index Item index to wait for
     */
    _waitForItem(index, timeout = MAXIMUM_TIME_WAITING_FOR_ITEM) {
        const selector = `[data-windowed-list-index="${index}"]`;
        const existing = this._innerElement.querySelector(selector);
        if (existing instanceof HTMLElement) {
            return Promise.resolve(existing);
        }
        return new Promise(resolve => {
            let timer = null;
            const observer = new MutationObserver(() => {
                const item = this._innerElement.querySelector(selector);
                if (item instanceof HTMLElement) {
                    observer.disconnect();
                    if (timer !== null) {
                        window.clearTimeout(timer);
                    }
                    resolve(item);
                }
            });
            observer.observe(this._innerElement, {
                childList: true,
                subtree: true
            });
            // Safety net to avoid leaking the observer if the item never gets
            // rendered
            timer = window.setTimeout(() => {
                observer.disconnect();
                if (this.isDisposed || !this.isAttached) {
                    resolve(null);
                    return;
                }
                const item = this._innerElement.querySelector(selector);
                console.warn(`Item with index ${index} did not appear within ${timeout} ms`);
                resolve(item instanceof HTMLElement ? item : null);
            }, timeout);
        });
    }
    /**
     * A message handler invoked on an `'after-attach'` message.
     */
    onAfterAttach(msg) {
        super.onAfterAttach(msg);
        if (this.viewModel.windowingActive) {
            this._applyWindowingStyles();
        }
        else {
            this._applyNoWindowingStyles();
        }
        this._addListeners();
        this.viewModel.height = this.node.getBoundingClientRect().height;
        const viewportStyle = window.getComputedStyle(this._viewport);
        this.viewModel.paddingTop = parseFloat(viewportStyle.paddingTop);
        this._viewportPaddingTop = this.viewModel.paddingTop;
        this._viewportPaddingBottom = parseFloat(viewportStyle.paddingBottom);
        this._scrollbarElement.addEventListener('pointerdown', this);
        this._outerElement.addEventListener('scrollend', this);
    }
    /**
     * A message handler invoked on an `'before-detach'` message.
     */
    onBeforeDetach(msg) {
        this._removeListeners();
        this._scrollbarElement.removeEventListener('pointerdown', this);
        this._outerElement.removeEventListener('scrollend', this);
        super.onBeforeDetach(msg);
    }
    /**
     * Callback on scroll event
     *
     * @param event Scroll event
     */
    onScroll(event) {
        const { clientHeight, scrollHeight, scrollTop } = event.currentTarget;
        // TBC Firefox is emitting two events one with 1px diff then the _real_ scroll
        if (!this._scrollUpdateWasRequested &&
            Math.abs(this.viewModel.scrollOffset - scrollTop) > 1) {
            // Test if the scroll event is jumping to the list bottom
            // if (Math.abs(scrollHeight - clientHeight - scrollTop) < 1) {
            //   // FIXME Does not work because it happens in multiple segments in between which the sizing is changing
            //   // due to widget resizing. A possible fix would be to keep track of the "old" scrollHeight - clientHeight
            //   // up to some quiet activity.
            //   this.scrollToItem(this.widgetCount, 'end');
            // } else {
            const scrollOffset = Math.max(0, Math.min(scrollTop, scrollHeight - clientHeight));
            this.viewModel.scrollOffset = scrollOffset;
            this._renderScrollbar();
            if (this.viewModel.windowingActive) {
                this._scrollUpdateWasRequested = false;
                if (this._viewport.dataset.isScrolling != 'true') {
                    this._viewport.dataset.isScrolling = 'true';
                }
                if (this._timerToClearScrollStatus) {
                    window.clearTimeout(this._timerToClearScrollStatus);
                }
                // TODO: remove once `scrollend` event is supported by Safari
                this._timerToClearScrollStatus = window.setTimeout(() => {
                    this._onScrollEnd();
                }, 750);
                this.update();
                // }
            }
            // }
        }
    }
    /**
     * A message handler invoked on an `'resize-request'` message.
     */
    onResize(msg) {
        const previousHeight = this.viewModel.height;
        this.viewModel.height =
            msg.height >= 0 ? msg.height : this.node.getBoundingClientRect().height;
        if (this.viewModel.height !== previousHeight) {
            void this._updater.invoke();
        }
        super.onResize(msg);
        void this._updater.invoke();
    }
    /**
     * Callback on view model change
     *
     * @param model Windowed list model
     * @param changes Change
     */
    onStateChanged(model, changes) {
        switch (changes.name) {
            case 'windowingActive':
                this._removeListeners();
                if (this.viewModel.windowingActive) {
                    this._applyWindowingStyles();
                    this.onScroll({ currentTarget: this.node });
                    this._addListeners();
                    // Bail as onScroll will trigger update
                    return;
                }
                else {
                    this._applyNoWindowingStyles();
                    this._addListeners();
                }
                break;
            case 'estimatedWidgetSize':
                // This only impact the container height and should not
                // impact the elements in the viewport.
                this._updateTotalSize();
                return;
        }
        this.update();
    }
    /**
     * A message handler invoked on an `'update-request'` message.
     *
     * #### Notes
     * The default implementation of this handler is a no-op.
     */
    onUpdateRequest(msg) {
        if (this.viewModel.windowingActive) {
            // Throttle update request
            if (this._scrollRepaint === null) {
                this._needsUpdate = false;
                this._scrollRepaint = window.requestAnimationFrame(() => {
                    this._scrollRepaint = null;
                    this._update();
                    if (this._needsUpdate) {
                        this.update();
                    }
                });
            }
            else {
                // Force re rendering if some changes happen during rendering.
                this._needsUpdate = true;
            }
        }
        else {
            this._update();
        }
    }
    /*
     * Hide the native scrollbar if necessary and update dimensions
     */
    _adjustDimensionsForScrollbar() {
        const outer = this._outerElement;
        const scrollbar = this._scrollbarElement;
        if (this.scrollbar) {
            // Query DOM
            let outerScrollbarWidth = outer.offsetWidth - outer.clientWidth;
            // Update DOM
            // 1) The native scrollbar is hidden by shifting it out of view.
            if (outerScrollbarWidth == 0) {
                // If the scrollbar width is zero, one of the following is true:
                // - (a) the content is not overflowing
                // - (b) the browser uses overlay scrollbars
                // In (b) the overlay scrollbars could show up even even if
                // occluded by a child element; to prevent this resulting in
                // double scrollbar we shift the content by an arbitrary offset.
                outerScrollbarWidth = 1000;
                outer.style.paddingRight = `${outerScrollbarWidth}px`;
                outer.style.boxSizing = 'border-box';
            }
            else {
                outer.style.paddingRight = '0';
            }
            outer.style.width = `calc(100% + ${outerScrollbarWidth}px)`;
            // 2) The inner window is shrank to accommodate the virtual scrollbar
            this._innerElement.style.marginRight = `${scrollbar.offsetWidth}px`;
        }
        else {
            // Reset all styles that may have been touched.
            outer.style.width = '100%';
            this._innerElement.style.marginRight = '';
            outer.style.paddingRight = '0';
            outer.style.boxSizing = '';
        }
    }
    /**
     * Add listeners for viewport, contents and the virtual scrollbar.
     */
    _addListeners() {
        // Scrollbar resize
        if (!this._scrollbarResizeObserver) {
            this._scrollbarResizeObserver = new ResizeObserver(this._adjustDimensionsForScrollbar.bind(this));
            this._scrollbarResizeObserver.observe(this._outerElement);
            this._scrollbarResizeObserver.observe(this._scrollbarElement);
        }
        // Observe all items for size changes, regardless of windowing mode
        if (!this._itemsResizeObserver) {
            this._itemsResizeObserver = new ResizeObserver(this._onItemResize.bind(this));
        }
        for (const widget of this.layout.widgets) {
            this._itemsResizeObserver.observe(widget.node);
            widget.disposed.connect(() => { var _a; return (_a = this._itemsResizeObserver) === null || _a === void 0 ? void 0 : _a.unobserve(widget.node); });
        }
        if (!this._areaResizeObserver && this.viewModel.windowingActive) {
            // This is taken care by browser scroll anchoring outside of full windowing mode.
            this._areaResizeObserver = new ResizeObserver(this._onAreaResize.bind(this));
            this._areaResizeObserver.observe(this._innerElement);
        }
        this._outerElement.addEventListener('scroll', this, passiveIfSupported);
    }
    /**
     * Turn off windowing related styles in the viewport.
     */
    _applyNoWindowingStyles() {
        this._outerElement.style.removeProperty('overflow-anchor');
        this._viewport.style.position = 'relative';
        this._viewport.style.contain = '';
        this._viewport.style.top = '0px';
        this._viewport.style.minHeight = '';
        this._innerElement.style.height = '';
    }
    /**
     * Turn on windowing related styles in the viewport.
     */
    _applyWindowingStyles() {
        this._outerElement.style.setProperty('overflow-anchor', 'none');
        this._viewport.style.position = 'absolute';
        this._viewport.style.contain = 'layout';
    }
    /**
     * Remove listeners for viewport and contents (but not the virtual scrollbar).
     */
    _removeListeners() {
        var _a, _b, _c;
        this._outerElement.removeEventListener('scroll', this);
        (_a = this._areaResizeObserver) === null || _a === void 0 ? void 0 : _a.disconnect();
        this._areaResizeObserver = null;
        (_b = this._itemsResizeObserver) === null || _b === void 0 ? void 0 : _b.disconnect();
        this._itemsResizeObserver = null;
        (_c = this._scrollbarResizeObserver) === null || _c === void 0 ? void 0 : _c.disconnect();
        this._scrollbarResizeObserver = null;
    }
    /**
     * Update viewport and DOM state.
     */
    _update() {
        var _a;
        if (this.isDisposed || !this.layout) {
            return;
        }
        const newWindowIndex = this.viewModel.getRangeToRender();
        if (newWindowIndex !== null) {
            const [startIndex, stopIndex] = newWindowIndex;
            this._renderScrollbar();
            const toAdd = [];
            if (stopIndex >= 0) {
                for (let index = startIndex; index <= stopIndex; index++) {
                    const widget = this.viewModel.widgetRenderer(index);
                    widget.dataset.windowedListIndex = `${index}`;
                    toAdd.push(widget);
                }
            }
            const nWidgets = this.layout.widgets.length;
            // Remove not needed widgets
            for (let itemIdx = nWidgets - 1; itemIdx >= 0; itemIdx--) {
                if (!toAdd.includes(this.layout.widgets[itemIdx])) {
                    (_a = this._itemsResizeObserver) === null || _a === void 0 ? void 0 : _a.unobserve(this.layout.widgets[itemIdx].node);
                    this.layout.removeWidget(this.layout.widgets[itemIdx]);
                }
            }
            for (let index = 0; index < toAdd.length; index++) {
                const item = toAdd[index];
                if (this._itemsResizeObserver && !this.layout.widgets.includes(item)) {
                    this._itemsResizeObserver.observe(item.node);
                    item.disposed.connect(() => { var _a; return (_a = this._itemsResizeObserver) === null || _a === void 0 ? void 0 : _a.unobserve(item.node); });
                }
                // The widget may have moved due to drag-and-drop
                this.layout.insertWidget(index, item);
            }
            if (this.viewModel.windowingActive) {
                if (stopIndex >= 0) {
                    // Read this value after creating the cells.
                    // So their actual sizes are taken into account
                    this._updateTotalSize();
                    // Update position of window container
                    let [top, _minHeight] = this.viewModel.getSpan(startIndex, stopIndex);
                    this._viewport.style.transform = `translateY(${top}px)`;
                }
                else {
                    // Update inner container height
                    this._innerElement.style.height = `0px`;
                    // Update position of viewport node
                    this._viewport.style.top = `0px`;
                    this._viewport.style.minHeight = `0px`;
                }
                // Update scroll
                if (this._scrollUpdateWasRequested) {
                    this._outerElement.scrollTop = this.viewModel.scrollOffset;
                    this._scrollUpdateWasRequested = false;
                    this._markProgrammaticScrollingDone();
                }
            }
        }
        let index2 = -1;
        for (const w of this._viewport.children) {
            const currentIdx = parseInt(w.dataset.windowedListIndex, 10);
            if (currentIdx < index2) {
                throw new Error('Inconsistent dataset index');
            }
            else {
                index2 = currentIdx;
            }
        }
    }
    /**
     * Handle viewport area resize.
     */
    _onAreaResize(_entries) {
        this._scrollBackToItemOnResize();
    }
    /**
     * Handle viewport content (i.e. items) resize.
     */
    _onItemResize(entries) {
        this._resetScrollToItem();
        if (this.isHidden || this.isParentHidden) {
            return;
        }
        const newSizes = [];
        for (let entry of entries) {
            // Update size only if item is attached to the DOM
            if (entry.target.isConnected) {
                // Rely on the data attribute as some nodes may be hidden instead of detach
                // to preserve state.
                newSizes.push({
                    index: parseInt(entry.target.dataset.windowedListIndex, 10),
                    size: entry.borderBoxSize[0].blockSize
                });
            }
        }
        // If some sizes changed
        if (this.viewModel.setWidgetSize(newSizes)) {
            this._scrollBackToItemOnResize();
            // Update the list
            this.update();
        }
    }
    /**
     * Scroll to the item which was most recently requested.
     *
     * This method ensures that the app scrolls to the item even if a resize event
     * occurs shortly after the scroll. Consider the following sequence of events:
     *
     * 1. User is at the nth cell, presses Shift+Enter (run current cell and
     *    advance to next)
     * 2. App scrolls to the next (n+1) cell
     * 3. The nth cell finishes running and renders the output, pushing the
     *    (n+1) cell down out of view
     * 4. This triggers the resize observer, which calls this method and scrolls
     *    the (n+1) cell back into view
     *
     * On implementation level, this is ensured by scrolling to `this._scrollToItem`
     * which is cleared after a short timeout once the scrolling settles
     * (see `this._resetScrollToItem()`).
     *
     * This is taken care by browser scroll anchoring outside of full windowing mode.
     */
    _scrollBackToItemOnResize() {
        if (!this.viewModel.windowingActive) {
            return;
        }
        if (!this._scrollToItem) {
            return;
        }
        this.scrollToItem(...this._scrollToItem).catch(reason => {
            console.log(reason);
        });
    }
    /**
     * Clear any outstanding timeout and enqueue scrolling to a new item.
     */
    _resetScrollToItem() {
        if (this._resetScrollToItemTimeout) {
            clearTimeout(this._resetScrollToItemTimeout);
        }
        if (this._programmaticScrollTimeout) {
            clearTimeout(this._programmaticScrollTimeout);
        }
        if (this._scrollToItem) {
            this._resetScrollToItemTimeout = window.setTimeout(() => {
                this._scrollToItem = null;
            }, MAXIMUM_TIME_FOR_SCROLLBACK);
        }
        if (this._isScrolling) {
            this._programmaticScrollTimeout = window.setTimeout(() => {
                // Do not resolve the programmatic scroll delegate while any scroll is
                // still waiting for a not-yet-rendered item (non-windowing path): the
                // scroll has not happened yet and will resolve the delegate itself
                // once `_waitForItem` settles (see `scrollToItem`).
                if (this._pendingItemWaits === 0) {
                    this._markProgrammaticScrollingDone();
                }
            }, PROGRAMMATIC_SCROLL_TIMEOUT);
        }
    }
    /**
     * Mark the current promise for programmatic scrolling as completed.
     */
    _markProgrammaticScrollingDone() {
        if (this._isScrolling) {
            this._isScrolling.resolve();
            this._isScrolling = null;
        }
    }
    /**
     * Render virtual scrollbar.
     */
    _renderScrollbar() {
        var _a, _b;
        if (!this.scrollbar) {
            return [];
        }
        const { node, renderer, viewModel } = this;
        const content = node.querySelector('.jp-WindowedPanel-scrollbar-content');
        const elements = [];
        const visitedKeys = new Set();
        const getElement = (item, index) => {
            if (item instanceof HTMLElement) {
                return item;
            }
            visitedKeys.add(item.key);
            const props = { index };
            const cachedItem = this._scrollbarItems[item.key];
            if (cachedItem && !cachedItem.isDisposed) {
                try {
                    return cachedItem.render(props);
                }
                catch (_a) {
                    return document.createElement('div'); // fallback for tests
                }
            }
            else {
                this._scrollbarItems[item.key] = item;
                try {
                    return item.render(props);
                }
                catch (_b) {
                    return document.createElement('div'); // fallback for tests
                }
            }
        };
        const list = viewModel.itemsList;
        const count = (_a = list === null || list === void 0 ? void 0 : list.length) !== null && _a !== void 0 ? _a : viewModel.widgetCount;
        for (let index = 0; index < count; index++) {
            const model = (_b = list === null || list === void 0 ? void 0 : list.get) === null || _b === void 0 ? void 0 : _b.call(list, index);
            const item = renderer.createScrollbarItem(this, index, model);
            const element = getElement(item, index);
            element.classList.add('jp-WindowedPanel-scrollbar-item');
            element.dataset.index = `${index}`;
            elements.push(element);
        }
        // dispose of any elements which are no longer in the scrollbar
        const keysNotSeen = Object.keys(this._scrollbarItems).filter(key => !visitedKeys.has(key));
        for (const key of keysNotSeen) {
            this._scrollbarItems[key].dispose();
            delete this._scrollbarItems[key];
        }
        const oldNodes = [...content.childNodes];
        if (oldNodes.length !== elements.length ||
            !oldNodes.every((node, index) => elements[index] === node)) {
            content.replaceChildren(...elements);
        }
        if (this.scrollbar) {
            const newWindowIndex = this.viewModel.getRangeToRender({ virtual: true });
            if (newWindowIndex !== null) {
                const [firstVisibleIndex, lastVisibleIndex] = newWindowIndex;
                const first = elements[firstVisibleIndex];
                const last = elements[lastVisibleIndex];
                this._viewportIndicator.style.top = first.offsetTop - 1 + 'px';
                this._viewportIndicator.style.height =
                    last.offsetTop - first.offsetTop + last.offsetHeight + 'px';
            }
        }
        return elements;
    }
    /**
     * Handle `pointerdown` events on the virtual scrollbar.
     */
    _evtPointerDown(event) {
        let target = event.target;
        while (target && target.parentElement) {
            if (target.hasAttribute('data-index')) {
                const index = parseInt(target.getAttribute('data-index'), 10);
                return void (async () => {
                    await this.scrollToItem(index);
                    this.jumped.emit(index);
                })();
            }
            target = target.parentElement;
        }
    }
    /**
     * Handle `scrollend` events on the scroller.
     */
    _onScrollEnd() {
        if (this._timerToClearScrollStatus) {
            window.clearTimeout(this._timerToClearScrollStatus);
        }
        this._viewport.dataset.isScrolling = 'false';
        if (this._requiresTotalSizeUpdate) {
            this._updateTotalSize();
        }
        this._requiresTotalSizeUpdate = false;
    }
    /**
     * Update the total size
     */
    _updateTotalSize() {
        if (this.viewModel.windowingActive) {
            if (this._viewport.dataset.isScrolling == 'true') {
                // Do not update while scrolling, delay until later
                this._requiresTotalSizeUpdate = true;
                return;
            }
            const estimatedTotalHeight = this.viewModel.getEstimatedTotalSize();
            const heightWithPadding = estimatedTotalHeight +
                this._viewportPaddingTop +
                this._viewportPaddingBottom;
            // Update inner container height
            this._innerElement.style.height = `${heightWithPadding}px`;
        }
    }
}
/**
 * Default widget size
 */
WindowedList.DEFAULT_WIDGET_SIZE = 50;
/**
 * Customized layout for windowed list container.
 */
export class WindowedLayout extends PanelLayout {
    /**
     * Constructor
     */
    constructor() {
        super({ fitPolicy: 'set-no-constraint' });
    }
    /**
     * Specialized parent type definition
     */
    get parent() {
        return super.parent;
    }
    set parent(value) {
        super.parent = value;
    }
    /**
     * Attach a widget to the parent's DOM node.
     *
     * @param index - The current index of the widget in the layout.
     *
     * @param widget - The widget to attach to the parent.
     *
     * #### Notes
     * This method is called automatically by the panel layout at the
     * appropriate time. It should not be called directly by user code.
     *
     * The default implementation adds the widgets's node to the parent's
     * node at the proper location, and sends the appropriate attach
     * messages to the widget if the parent is attached to the DOM.
     *
     * Subclasses may reimplement this method to control how the widget's
     * node is added to the parent's node.
     */
    attachWidget(index, widget) {
        // Look up the next sibling reference node.
        let ref = this.parent.viewportNode.children[index];
        // Send a `'before-attach'` message if the parent is attached.
        if (this.parent.isAttached) {
            MessageLoop.sendMessage(widget, Widget.Msg.BeforeAttach);
        }
        // Insert the widget's node before the sibling.
        this.parent.viewportNode.insertBefore(widget.node, ref);
        // Send an `'after-attach'` message if the parent is attached.
        if (this.parent.isAttached) {
            MessageLoop.sendMessage(widget, Widget.Msg.AfterAttach);
        }
    }
    /**
     * Detach a widget from the parent's DOM node.
     *
     * @param index - The previous index of the widget in the layout.
     *
     * @param widget - The widget to detach from the parent.
     *
     * #### Notes
     * This method is called automatically by the panel layout at the
     * appropriate time. It should not be called directly by user code.
     *
     * The default implementation removes the widget's node from the
     * parent's node, and sends the appropriate detach messages to the
     * widget if the parent is attached to the DOM.
     *
     * Subclasses may reimplement this method to control how the widget's
     * node is removed from the parent's node.
     */
    detachWidget(index, widget) {
        // Send a `'before-detach'` message if the parent is attached.
        if (this.parent.isAttached) {
            MessageLoop.sendMessage(widget, Widget.Msg.BeforeDetach);
        }
        // Remove the widget's node from the parent.
        this.parent.viewportNode.removeChild(widget.node);
        // Send an `'after-detach'` message if the parent is attached.
        if (this.parent.isAttached) {
            MessageLoop.sendMessage(widget, Widget.Msg.AfterDetach);
        }
    }
    /**
     * Move a widget in the parent's DOM node.
     *
     * @param fromIndex - The previous index of the widget in the layout.
     *
     * @param toIndex - The current index of the widget in the layout.
     *
     * @param widget - The widget to move in the parent.
     *
     * #### Notes
     * This method is called automatically by the panel layout at the
     * appropriate time. It should not be called directly by user code.
     *
     * The default implementation moves the widget's node to the proper
     * location in the parent's node and sends the appropriate attach and
     * detach messages to the widget if the parent is attached to the DOM.
     *
     * Subclasses may reimplement this method to control how the widget's
     * node is moved in the parent's node.
     */
    moveWidget(fromIndex, toIndex, widget) {
        // Optimize move without de-/attaching as motion appends with parent attached
        // Case fromIndex === toIndex, already checked in PanelLayout.insertWidget
        // Look up the next sibling reference node.
        let ref = this.parent.viewportNode.children[toIndex];
        if (fromIndex < toIndex) {
            ref.insertAdjacentElement('afterend', widget.node);
        }
        else {
            ref.insertAdjacentElement('beforebegin', widget.node);
        }
    }
    /**
     * A message handler invoked on an `'update-request'` message.
     *
     * #### Notes
     * This is a reimplementation of the base class method,
     * and is a no-op.
     */
    onUpdateRequest(msg) {
        // This is a no-op.
    }
}
/**
 * A namespace for windowed list
 */
(function (WindowedList) {
    /**
     * The default renderer class for windowed lists.
     */
    class Renderer {
        /**
         * Create the outer, root element of the windowed list.
         */
        createOuter() {
            return document.createElement('div');
        }
        /**
         * Create the virtual scrollbar element.
         */
        createScrollbar() {
            return document.createElement('ol');
        }
        /**
         * Create the virtual scrollbar viewport indicator.
         */
        createScrollbarViewportIndicator() {
            return document.createElement('div');
        }
        /**
         * Create an individual item rendered in the scrollbar.
         */
        createScrollbarItem(_, index) {
            const li = document.createElement('li');
            li.appendChild(document.createTextNode(`${index}`));
            return li;
        }
        /**
         * Create the viewport element into which virtualized children are added.
         */
        createViewport() {
            return document.createElement('div');
        }
    }
    WindowedList.Renderer = Renderer;
    /**
     * The default renderer for windowed lists.
     */
    WindowedList.defaultRenderer = new Renderer();
})(WindowedList || (WindowedList = {}));
//# sourceMappingURL=windowedlist.js.map