summaryrefslogtreecommitdiffstats
path: root/comm/mail/modules/SelectionWidgetController.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/modules/SelectionWidgetController.jsm')
-rw-r--r--comm/mail/modules/SelectionWidgetController.jsm1355
1 files changed, 1355 insertions, 0 deletions
diff --git a/comm/mail/modules/SelectionWidgetController.jsm b/comm/mail/modules/SelectionWidgetController.jsm
new file mode 100644
index 0000000000..267ff7902b
--- /dev/null
+++ b/comm/mail/modules/SelectionWidgetController.jsm
@@ -0,0 +1,1355 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["SelectionWidgetController"];
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+/**
+ * @callback GetLayoutDirectionMethod
+ *
+ * @returns {"horizontal"|"vertical"} - The direction in which the widget
+ * visually lays out its items. "vertical" for top to bottom, "horizontal" for
+ * following the text direction.
+ */
+/**
+ * Details about the sizing of the widget in the same direction as its layout.
+ *
+ * @typedef {object} PageSizeDetails
+ * @param {number} viewSize - The size of the widget's "view" of its items. If
+ * the items are placed under a scrollable area with 0 padding, this would
+ * usually be the clientHeight or clientWidth, which exclude the border and
+ * the scroll bars.
+ * @param {number} viewOffset - The offset of the widget's "view" from the
+ * starting item. If the items are placed under a scrollable area with 0
+ * padding, this would usually be its scrollTop, or the absolute value of its
+ * scrollLeft (to account for negative values in right-to-left).
+ * @param {?number} itemSize - The size of an item. If the items have no spacing
+ * between them, then this would usually correspond to their bounding client
+ * widths or heights. If the items do not share the same size, or there are no
+ * items this should return null.
+ */
+/**
+ * @callback GetPageSizeDetailsMethod
+ *
+ * @returns {?PageSizeDetails} Details about the currently visible items. Or null
+ * if page navigation should not be allowed: either because the required
+ * conditions do not apply or PageUp and PageDown should be used for something
+ * else.
+ */
+/**
+ * @callback IndexFromTargetMethod
+ *
+ * @param {EventTarget} target - An event target.
+ *
+ * @returns {?number} - The index for the selectable item that contains the event
+ * target, or null if there is none.
+ */
+/**
+ * @callback SetFocusableItemMethod
+ *
+ * @param {?number} index - The index for the selectable item that should become
+ * focusable, replacing any previous focusable item. Or null if the widget
+ * itself should become focusable instead. If the corresponding item was not
+ * previously the focused item and it is not yet visible, it should be scrolled
+ * into view.
+ * @param {boolean} focus - Whether to also focus the specified item after it
+ * becomes focusable.
+ */
+/**
+ * @callback SetItemSelectionStateMethod
+ *
+ * @param {number} index - The index of the first selectable items to set the
+ * selection state of.
+ * @param {number} number - The number of subsequent selectable items that
+ * should be set to the same selection state, including the first item and any
+ * immediately following it.
+ * @param {boolean} selected - Whether the specified items should be selected or
+ * unselected.
+ */
+
+/**
+ * A class for handling the focus and selection controls for a widget.
+ *
+ * The widget is assumed to control a totally ordered set of selectable items,
+ * each of which may be referenced by their index in this ordering. The visual
+ * display of these items has an ordering that is faithful to this ordering.
+ * Note, a "selectable item" is any item that may receive focus and can be
+ * selected or unselected.
+ *
+ * A SelectionWidgetController instance will keep track of its widget's focus
+ * and selection states, and will provide a standard set of keyboard and mouse
+ * controls to the widget that handle changes in these states.
+ *
+ * The SelectionWidgetController instance will communicate with the widget to
+ * inform it of any changes in these states that the widget should adjust to. It
+ * may also query the widget for information as needed.
+ *
+ * The widget must inform its SelectionWidgetController instance of any changes
+ * in the index of selectable items. In particular, the widget should call the
+ * addedSelectableItems method to inform the controller of any initial set of
+ * items or any additional items that are added to the widget. It should also
+ * use the removeSelectableItems and moveSelectableItems methods when it wishes
+ * to remove or move items.
+ *
+ * The communication between the widget and its SelectionWidgetController
+ * instance will use the item's index to reference the item. This means that the
+ * representation of the item itself is left up to the widget.
+ *
+ * # Selection models
+ *
+ * The controller implements a number of selection models. Each of which has
+ * different selection features and controls suited to them. A model appropriate
+ * to the specific situation should be chosen.
+ *
+ * Model behaviour table:
+ *
+ * Model Name | Selection follows focus | Multi selectable
+ * ==========================================================================
+ * focus always no
+ * browse default no
+ * browse-multi default yes
+ *
+ *
+ * ## Behaviour: Selection follows focus
+ *
+ * This determines whether the focused item is selected.
+ *
+ * "always" means a focused item will always be selected, and no other item will
+ * be selected, which makes the selection redundant to the focus. This should be
+ * used if a change in the selection has no side effect beyond what a change in
+ * focus should trigger.
+ *
+ * "default" means the default action when navigating to a focused item is to
+ * change the selection to just that item, but the user may press a modifier
+ * (Control) to move the focus without selecting an item. The side effects to
+ * selecting an item should be light and non-disruptive since a user will likely
+ * change the selection regularly as they navigate the items without a modifier.
+ * Moreover, this behaviour will prefer selecting a single item, and so is not
+ * appropriate if the primary use case is to select multiple, or zero, items.
+ *
+ * ## Behaviour: Multi selectable
+ *
+ * This determines whether the user can select more than one item. If the
+ * selection follows the focus (by default) the user can use a modifier to
+ * select more than one item.
+ *
+ * Note, if this is "no", then in most usage, exactly one item will be selected.
+ * However, it is still possible to get into a state where no item is selected
+ * when the widget is empty or the selected item is deleted when it doesn't have
+ * focus.
+ */
+class SelectionWidgetController {
+ /**
+ * The widget this controller controls.
+ *
+ * @type {Element}
+ */
+ #widget = null;
+ /**
+ * A collection of methods passed to the controller at initialization.
+ *
+ * @type {object}
+ */
+ #methods = null;
+ /**
+ * The number of items the controller controls.
+ *
+ * @type {number}
+ */
+ #numItems = 0;
+ /**
+ * A range that points to all selectable items whose index `i` obeys
+ * `start <= i < end`
+ * Note, the `start` is inclusive of the index but the `end` is not.
+ *
+ * @typedef {object} SelectionRange
+ * @property {number} start - The starting point of the range.
+ * @property {number} end - The ending point of the range.
+ */
+ /**
+ * The ranges of selected indices, ordered by their `start` property.
+ *
+ * Each range is kept "disjoint": no natural number N obeys
+ * `#ranges[i].start <= N <= #ranges[i].end`
+ * for more than one index `i`. Essentially, this means that no range of
+ * selected items will overlap, or even be immediately adjacent to
+ * another set of selected items. Instead, if two ranges would be adjacent or
+ * overlap, they will be merged into one range instead.
+ *
+ * We use ranges, rather than a list of indices to reduce the footprint when a
+ * large number of items are selected. Similarly, we also avoid looping over
+ * all selected indices.
+ *
+ * @type {SelectionRange[]}
+ */
+ #ranges = [];
+ /**
+ * The direction of travel when holding the Shift modifier, or null if some
+ * other selection has broken the Shift selection sequence.
+ *
+ * @type {"forward"|"backward"|null}
+ */
+ #shiftRangeDirection = null;
+ /**
+ * The index of the focused selectable item, or null if the widget is focused
+ * instead.
+ *
+ * @type {?number}
+ */
+ #focusIndex = null;
+ /**
+ * Whether the focused item must always be selected.
+ *
+ * @type {boolean}
+ */
+ #focusIsSelected = false;
+ /**
+ * Whether the user can select multiple items.
+ *
+ * @type {boolean}
+ */
+ #multiSelectable = false;
+
+ /**
+ * Creates a new selection controller for the given widget.
+ *
+ * @param {widget} widget - The widget to control.
+ * @param {"focus"|"browse"|"browse-multi"} model - The selection model to
+ * follow.
+ * @param {object} methods - Methods for the controller to communicate with
+ * the widget.
+ * @param {GetLayoutDirectionMethod} methods.getLayoutDirection - Used to
+ * get the layout direction of the widget.
+ * @param {IndexFromTargetMethod} methods.indexFromTarget - Used to get the
+ * corresponding item index from an event target.
+ * @param {GetPageSizeDetailsMethod} method.getPageSizeDetails - Used to get
+ * details about the visible display of the widget items for page
+ * navigation.
+ * @param {SetFocusableItemMethod} methods.setFocusableItem - Used to update
+ * the widget on which item should receive focus.
+ * @param {SetItemSelectionStateMethod} methods.setItemSelectionState - Used
+ * to update the widget on whether a range of items should be selected.
+ */
+ constructor(widget, model, methods) {
+ this.#widget = widget;
+ switch (model) {
+ case "focus":
+ this.#focusIsSelected = true;
+ this.#multiSelectable = false;
+ break;
+ case "browse":
+ this.#focusIsSelected = false;
+ this.#multiSelectable = false;
+ break;
+ case "browse-multi":
+ this.#focusIsSelected = false;
+ this.#multiSelectable = true;
+ break;
+ default:
+ throw new RangeError(`The model "${model}" is not a supported model`);
+ }
+ this.#methods = methods;
+
+ widget.addEventListener("mousedown", event => this.#handleMouseDown(event));
+ if (this.#multiSelectable) {
+ widget.addEventListener("click", event => this.#handleClick(event));
+ }
+ widget.addEventListener("keydown", event => this.#handleKeyDown(event));
+ widget.addEventListener("focusin", event => this.#handleFocusIn(event));
+ }
+
+ #assertIntegerInRange(integer, lower, upper, name) {
+ if (!Number.isInteger(integer)) {
+ throw new RangeError(`"${name}" ${integer} is not an integer`);
+ }
+ if (lower != null && integer < lower) {
+ throw new RangeError(
+ `"${name}" ${integer} is not greater than or equal to ${lower}`
+ );
+ }
+ if (upper != null && integer > upper) {
+ throw new RangeError(
+ `"${name}" ${integer} is not less than or equal to ${upper}`
+ );
+ }
+ }
+
+ /**
+ * Update the widget's selection state for the specified items.
+ *
+ * @param {number} index - The index at which to start.
+ * @param {number} number - The number of items to set the state of.
+ */
+ #updateWidgetSelectionState(index, number) {
+ // First, inform the widget of the selection state of the new items.
+ let prevRangeEnd = index;
+ for (let { start, end } of this.#ranges) {
+ // Deselect the items in the gap between the previous range and this one.
+ // For the first range, there may not be a gap.
+ if (start > prevRangeEnd) {
+ this.#methods.setItemSelectionState(
+ prevRangeEnd,
+ start - prevRangeEnd,
+ false
+ );
+ }
+ // Select the items in the range.
+ this.#methods.setItemSelectionState(start, end - start, true);
+ prevRangeEnd = end;
+ }
+ // Deselect the items in the gap between the final range and the end of the
+ // new items, if there is a gap.
+ if (index + number > prevRangeEnd) {
+ this.#methods.setItemSelectionState(
+ prevRangeEnd,
+ index + number - prevRangeEnd,
+ false
+ );
+ }
+ }
+
+ /**
+ * Informs the controller that a set of selectable items were added to the
+ * widget. It is important to call this *after* the widget has indexed the new
+ * items.
+ *
+ * @param {number} index - The index at which the selectable items were added.
+ * Between 0 and the current number of items (inclusive).
+ * @param {number} number - The number of selectable items that were added at
+ * this index.
+ */
+ addedSelectableItems(index, number) {
+ this.#assertIntegerInRange(index, 0, this.#numItems, "index");
+ this.#assertIntegerInRange(number, 1, null, "number");
+ // Newly added items are unselected.
+ this.#adjustRangesOnAddItems(index, number, []);
+ this.#numItems += number;
+
+ if (this.#focusIndex != null && this.#focusIndex >= index) {
+ // Focus remains on the same item, but is adjusted in index.
+ this.#focusIndex += number;
+ }
+
+ this.#updateWidgetSelectionState(index, number);
+ }
+
+ /**
+ * Adjust the #ranges to account for additional inserted items.
+ *
+ * @param {number} index - The index at which items are added.
+ * @param {number} number - The number of items that are added at this index.
+ * @param {SelectionRange[]} insertSelection - The selection state of the
+ * inserted items. The ranges should be "disjoint" and only overlap the
+ * added indices. The given array is owned by the method.
+ */
+ #adjustRangesOnAddItems(index, number, insertSelection) {
+ // We want to insert whatever ranges are specified in insertSelection into
+ // the #ranges Array. insertRangeIndex tracks the index at which we will
+ // insert the given insertSelection.
+ let insertRangeIndex = 0;
+ // However, if insertSelection touches the start or end of the new items, it
+ // may be possible to merge it with an existing SelectionRange that touches
+ // the same edge.
+ let touchStartRange =
+ insertSelection.length && insertSelection[0].start == index
+ ? insertSelection[0]
+ : null;
+ let touchEndRange =
+ insertSelection.length &&
+ insertSelection[insertSelection.length - 1].end == index + number
+ ? insertSelection[insertSelection.length - 1]
+ : null;
+
+ // Go through ranges from last to first.
+ for (let i = this.#ranges.length - 1; i >= 0; i--) {
+ let { start, end } = this.#ranges[i];
+ if (touchStartRange && end == index) {
+ // Merge the range with touchStartRange.
+ touchStartRange.start = start;
+ this.#ranges.splice(i, 1, ...insertSelection);
+ // All earlier ranges should end strictly before the index.
+ return;
+ }
+ if (end <= index) {
+ // A B [ C D E ] F G
+ // ^start end^
+ // ^index (or higher)
+ // No change, and all earlier ranges are also before.
+ // This is the last range that lies before the inserted items, so we
+ // want to insert the given insertSelection after this range.
+ insertRangeIndex = i + 1;
+ break;
+ }
+ if (start < index) {
+ // start < index < end
+ // A B [ C D E ] F G
+ // ^start end^
+ // ^index
+ // The range is split in two parts by the index.
+ if (touchEndRange) {
+ // Extend touchEndRange to the end part of the current range.
+ // We add "number" to account for the inserted indices.
+ touchEndRange.end = end + number;
+ } else {
+ // Append a new range for the end part of the current range.
+ insertSelection.push({ start: index + number, end: end + number });
+ }
+ if (touchStartRange) {
+ // We merge touchStartRange with the first part of the current range.
+ touchStartRange.start = start;
+ this.#ranges.splice(i, 1, ...insertSelection);
+ } else {
+ // We adjust the first part to end where the inserted indices begin.
+ this.#ranges[i].end = index;
+ this.#ranges.splice(i + 1, 0, ...insertSelection);
+ }
+ // All earlier ranges should end strictly before the index.
+ return;
+ }
+ // A B [ C D E ] F G
+ // ^start end^
+ // ^index (or lower)
+ if (touchEndRange && start == index) {
+ // Merge the range with the touchEndRange.
+ // We add "number" to account for the inserted indices.
+ touchEndRange.end = end + number;
+ this.#ranges.splice(i, 1, ...insertSelection);
+ // All earlier ranges should end strictly before the index.
+ return;
+ }
+ // Shift the range to account for the inserted indices.
+ this.#ranges[i].start = start + number;
+ this.#ranges[i].end = end + number;
+ }
+
+ // Add the insert ranges in the gap.
+ if (insertSelection.length) {
+ this.#ranges.splice(insertRangeIndex, 0, ...insertSelection);
+ }
+ }
+
+ /**
+ * Remove a set of selectable items from the widget. The actual removing of
+ * the items and their elements from the widget is controlled by the widget
+ * through a callback, and the controller will update its internals. The
+ * controller may also change the selection state and focus of the widget
+ * if need be.
+ *
+ * @param {number} index - The index of the first selectable item to be
+ * removed.
+ * @param {number} number - The number of subsequent selectable items that
+ * will be removed, including the first item and any immediately following
+ * it.
+ * @param {Function} removeCallback - A function to call with no arguments
+ * that removes the specified items from the widget. After this call the
+ * widget should no longer be tracking the specified items and should have
+ * shifted the indices of the remaining items to fill the gap.
+ */
+ removeSelectableItems(index, number, removeCallback) {
+ this.#assertIntegerInRange(index, 0, this.#numItems - 1, "index");
+ this.#assertIntegerInRange(number, 1, this.#numItems - index, "number");
+
+ let focusWasSelected =
+ this.#focusIndex != null && this.itemIsSelected(this.#focusIndex);
+ // Get whether the focus is within the widget now in case it is lost when
+ // the items are removed.
+ let focusInWidget = this.#focusInWidget();
+
+ removeCallback();
+
+ this.#adjustRangesOnRemoveItems(index, number);
+ this.#numItems -= number;
+
+ if (!this.#ranges.length) {
+ // Ends any shift range.
+ this.#shiftRangeDirection = null;
+ }
+
+ // Adjust focus.
+ if (this.#focusIndex == null || this.#focusIndex < index) {
+ // No change in index if on widget or before the removed index.
+ return;
+ }
+ if (this.#focusIndex >= index + number) {
+ // Reduce index if after the removed items.
+ this.#focusIndex -= number;
+ return;
+ }
+ // Focus is lost.
+ // Try to move to the first item after the removed items. If this does
+ // not exist, it will be capped to the last item overall in #moveFocus.
+ let newFocus = index;
+ if (focusWasSelected && this.#shiftRangeDirection) {
+ // As a special case, if the focused item was inside a shift selection
+ // range when it was removed, and the range still exists after, we keep
+ // the focus within the selection boundary that is opposite the "pivot"
+ // point. I.e. when selecting forwards we keep the focus below the
+ // selection end, and when selecting backwards we keep the focus above the
+ // selection start. This is to prevent the focused item becoming
+ // unselected in the middle of an ongoing shift range selection.
+ // NOTE: When selecting forwards, we do not keep the focus above the
+ // selection start because the user would only be here (at the selection
+ // "pivot") if they navigated with Ctrl+Space to this position, so we do
+ // not override the default behaviour. Similarly when selecting backwards
+ // we do not require the focus to remain above the selection end.
+ switch (this.#shiftRangeDirection) {
+ case "forward":
+ newFocus = Math.min(
+ newFocus,
+ this.#ranges[this.#ranges.length - 1].end - 1
+ );
+ break;
+ case "backward":
+ newFocus = Math.max(newFocus, this.#ranges[0].start);
+ }
+ }
+ // TODO: if we have a tree structure, we will want to move the focus
+ // within the nearest parent by clamping the focus to lie between the
+ // parent index (inclusive) and its last descendant (inclusive). If
+ // there are no children left, this will fallback to focusing the
+ // parent.
+ this.#moveFocus(newFocus, focusInWidget);
+ // #focusIndex may now be different from newFocus if the deleted indices
+ // were the final ones, and may be null if no items remain.
+ if (!this.#ranges.length && this.#focusIndex != null) {
+ // If the focus was moved, and now we have no selection, we select it.
+ // This is deemed relatively safe to do since it only effects the state of
+ // the focused item. And it is convenient to have selection resume.
+ this.#selectSingle(this.#focusIndex);
+ }
+ }
+
+ /**
+ * Adjust the #ranges to remove items.
+ *
+ * @param {number} index - The index at which items are removed.
+ * @param {number} number - The number of items that are removed.
+ *
+ * @returns {SelectionRange[]} - The removed SelectionRange objects. This will
+ * contain all the ranges that touched or overlapped the selected items.
+ * Owned by the caller.
+ */
+ #adjustRangesOnRemoveItems(index, number) {
+ // The ranges to remove.
+ let deleteRangesStart = 0;
+ let deleteRangesNumber = 0;
+ // The range to insert by combining overlapping ranges on either side of the
+ // deleted indices.
+ let insertRange = { start: index, end: index };
+
+ // Go through ranges from last to first.
+ for (let i = this.#ranges.length - 1; i >= 0; i--) {
+ let { start, end } = this.#ranges[i];
+ if (end < index) {
+ // <- removed ->
+ // A B C D E [ F G H ] I J K L M
+ // ^start end^
+ // ^index (or higher)
+ deleteRangesStart = i + 1;
+ // This and all earlier ranges do not need to be updated.
+ break;
+ } else if (start > index + number) {
+ // <- removed ->
+ // A B C D E [ F G H ] I J K L M
+ // ^start end^
+ // ^index + number (or lower)
+ // Shift the range.
+ this.#ranges[i].start = start - number;
+ this.#ranges[i].end = end - number;
+ continue;
+ }
+ deleteRangesNumber++;
+ if (end > index + number) {
+ // start <= (index + number) < end
+ // <- removed ->
+ // A B C D E [ F G H ] I J K L M
+ // ^start end^
+ // ^index ^index + number
+ //
+ // <- removed ->
+ // A B C D E [ F G H ] I J K L M
+ // ^start end^
+ // ^index ^index + number
+ //
+ // <- removed ->
+ // A B C [ D E F G H I ] J K L M
+ // ^start end^
+ // ^index ^index + number
+ //
+ // Overlaps or touches the end of the removed indices, but is not
+ // entirely contained within the removed region.
+ // Extend the insertRange to the end of this range, and then shift it to
+ // remove the deleted indices.
+ insertRange.end = end - number;
+ }
+ if (start < index) {
+ // start < index <= end
+ // <- removed ->
+ // A B C D E [ F G H ] I J K L M
+ // ^start end^
+ // ^index ^index + number
+ //
+ // <- removed ->
+ // A B C D E [ F G H ] I J K L M
+ // ^start end^
+ // ^index ^index + number
+ //
+ // <- removed ->
+ // A B C [ D E F G H I ] J K L M
+ // ^start end^
+ // ^index ^index + number
+ //
+ // Overlaps or touches the start of the removed indices, but is not
+ // entirely contained within the removed region.
+ // Extend the insertRange to the start of this range.
+ insertRange.start = start;
+ // Expect break on next loop.
+ }
+ }
+ if (!deleteRangesNumber) {
+ // No change in selection.
+ return [];
+ }
+ if (insertRange.end > insertRange.start) {
+ return this.#ranges.splice(
+ deleteRangesStart,
+ deleteRangesNumber,
+ insertRange
+ );
+ }
+ // No range to insert.
+ return this.#ranges.splice(deleteRangesStart, deleteRangesNumber);
+ }
+
+ /**
+ * Move a set of selectable items within the widget. The actual moving of
+ * the items and their elements in the widget is controlled by the widget
+ * through a callback, and the controller will update its internals.
+ *
+ * Unlike simply adding and then removing indices, this will transfer the
+ * focus and selection states along with the moved items.
+ *
+ * @param {number} from - The index of the first selectable item to be
+ * moved, before the move.
+ * @param {number} to - The index that the first selectable item will be moved
+ * to, after the move.
+ * @param {number} number - The number of subsequent selectable items that
+ * will be moved along with the first item, including the first item and any
+ * immediately following it. Their relative positions should remain the
+ * same.
+ * @param {Function} moveCallback - A function to call with no arguments
+ * that moves the specified items within the widget to the specified
+ * position. After this call the widget should have adjusted the indices
+ * of its items accordingly.
+ */
+ moveSelectableItems(from, to, number, moveCallback) {
+ this.#assertIntegerInRange(from, 0, this.#numItems - 1, "from");
+ this.#assertIntegerInRange(number, 1, this.#numItems - from, "number");
+ this.#assertIntegerInRange(to, 0, this.#numItems - number, "to");
+ // Get whether the focus is within the widget now in case it is lost when
+ // the items are moved.
+ let focusInWidget = this.#focusInWidget();
+
+ moveCallback();
+
+ let movedSelection = this.#adjustRangesOnRemoveItems(from, number);
+ // Descend the removed ranges.
+ for (let i = movedSelection.length - 1; i >= 0; i--) {
+ let range = movedSelection[i];
+ if (range.end <= from || range.start >= from + number) {
+ // Touched the start or end, but did not overlap.
+ movedSelection.splice(i, 1);
+ // NOTE: Since we are descending it is safe to continue the loop by
+ // decreasing i by 1.
+ continue;
+ }
+ // Translate and clip the range.
+ range.start = to + Math.max(0, range.start - from);
+ range.end = to + Math.min(number, range.end - from);
+ }
+ this.#adjustRangesOnAddItems(to, number, movedSelection);
+
+ // End any range selection.
+ this.#shiftRangeDirection = null;
+
+ // Adjust focus.
+ if (this.#focusIndex != null) {
+ if (this.#focusIndex >= from && this.#focusIndex < from + number) {
+ // Focus was in the moved range.
+ // We adjust the #focusIndex, but we also force the widget to reset the
+ // focus in case it needs to apply it to a newly created items.
+ this.#moveFocus(this.#focusIndex + to - from, focusInWidget);
+ } else {
+ // Adjust for removing `number` items at `from`.
+ if (this.#focusIndex >= from + number) {
+ this.#focusIndex -= number;
+ }
+ // Adjust for then adding `number` items at `to`.
+ if (this.#focusIndex >= to) {
+ this.#focusIndex += number;
+ }
+ }
+ }
+ // Reset the selection state for the moved items in case it needs to be
+ // applied to newly created items.
+ this.#updateWidgetSelectionState(to, number);
+ }
+
+ /**
+ * Select the specified item and deselect all other items. The next time the
+ * widget is entered by the user, the specified item will also receive the
+ * focus.
+ *
+ * This should normally not be used in a situation were the focus may already
+ * be within the widget because it will actively move the focus, which can be
+ * disruptive if unexpected. It is mostly exposed to set an initial selection
+ * after creating the widget, or when changing its dataset.
+ *
+ * @param {number} index - The index for the item to select. This must not
+ * exceed the number of items controlled by the widget.
+ */
+ selectSingleItem(index) {
+ this.#selectSingle(index);
+ let focusInWidget = this.#focusInWidget();
+ if (this.#focusIndex == null && !focusInWidget) {
+ // Wait until handleFocusIn to move the focus to the selected item in case
+ // other items become selected through setItemSelected.
+ return;
+ }
+ this.#moveFocus(index, focusInWidget);
+ }
+
+ /**
+ * Set the selection state of the specified item, but otherwise leave the
+ * selection state of other items the same.
+ *
+ * Note that this will throw if the selection model does not support multi
+ * selection. Generally, you should try and use selectSingleItem instead
+ * because this also moves the focus appropriately and works for all models.
+ *
+ * @param {number} index - The index for the item to set the selection state
+ * of.
+ * @param {boolean} selected - Whether the item should be selected or
+ * unselected.
+ */
+ setItemSelected(index, selected) {
+ if (!this.#multiSelectable) {
+ throw new Error("Widget does not support multi-selection");
+ }
+ this.#toggleSelection(index, !!selected);
+ }
+
+ /**
+ * Get the ranges of all selected items.
+ *
+ * Note that ranges are returned rather than individual indices to keep this
+ * method fast. Unlike the selected indices which might become very large with
+ * a single user operation, like Select-All, the number of ranges will
+ * increase by order-one range per user interaction or public method call.
+ *
+ * Note that the SelectionRange objects specify the range with a `start` and
+ * `end` index. The `start` is inclusive of the index, but the `end` is
+ * not.
+ *
+ * Note that the returned Array is static (it will not update as the selection
+ * changes).
+ *
+ * @returns {SelectionRange[]} - An array of all non-overlapping selection
+ * ranges, order by their start index.
+ */
+ getSelectionRanges() {
+ return Array.from(this.#ranges, r => {
+ return { start: r.start, end: r.end };
+ });
+ }
+
+ /**
+ * Query whether the specified item is selected or not.
+ *
+ * @param {number} index - The index for the item to query.
+ *
+ * @returns {boolean} - Whether the item is selected.
+ */
+ itemIsSelected(index) {
+ this.#assertIntegerInRange(index, 0, this.#numItems - 1, "index");
+ for (let { start, end } of this.#ranges) {
+ if (index < start) {
+ // index was not in any lower ranges and is before the start of this
+ // range, so should be unselected.
+ return false;
+ }
+ if (index < end) {
+ // start <= index < end
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Select the specified range of indices, and nothing else.
+ *
+ * @param {number} index - The first index to select.
+ * @param {number} number - The number of indices to select.
+ */
+ #selectRange(index, number) {
+ this.#assertIntegerInRange(index, 0, this.#numItems - 1, "index");
+ this.#assertIntegerInRange(number, 1, this.#numItems - index, "number");
+
+ let prevRanges = this.#ranges;
+ let start = index;
+ let end = index + number;
+ if (
+ prevRanges.length == 1 &&
+ prevRanges[0].start == start &&
+ prevRanges[0].end == end
+ ) {
+ // No change.
+ return;
+ }
+
+ this.#ranges = [{ start, end }];
+ // Adjust the selection state to match the new range.
+ // NOTE: For simplicity, we do a blanket re-selection across the whole
+ // region, even items in between ranges that are not selected.
+ // NOTE: If the new range overlaps the previous range then the selection
+ // state be set more than once for an item, but it will be to the same
+ // value.
+ if (prevRanges.length) {
+ let firstRangeStart = prevRanges[0].start;
+ let lastRangeEnd = prevRanges[prevRanges.length - 1].end;
+ this.#updateWidgetSelectionState(
+ firstRangeStart,
+ lastRangeEnd - firstRangeStart
+ );
+ }
+ this.#updateWidgetSelectionState(index, number);
+ }
+
+ /**
+ * Select one index and nothing else.
+ *
+ * @param {number} index - The index to select.
+ */
+ #selectSingle(index) {
+ this.#selectRange(index, 1);
+ // Cancel any shift range.
+ this.#shiftRangeDirection = null;
+ }
+
+ /**
+ * Toggle the selection state at a single index.
+ *
+ * @param {number} index - The index to toggle the selection state of.
+ * @param {boolean} [selectState] - The state to force the selection state of
+ * the item to, or leave undefined to toggle the state.
+ */
+ #toggleSelection(index, selectState) {
+ this.#assertIntegerInRange(index, 0, this.#numItems - 1, "index");
+
+ let wasSelected = false;
+ let i;
+ // We traverse over the ranges.
+ for (i = 0; i < this.#ranges.length; i++) {
+ let { start, end } = this.#ranges[i];
+ // Test if in a gap between the end of last range and the start of the
+ // current one.
+ // NOTE: Since we did not break on the previous loop, we already know that
+ // the index is above the end of the previous range.
+ if (index < start) {
+ // This index is not selected.
+ break;
+ }
+ // Test if in the range.
+ if (index < end) {
+ // start <= index < end
+ wasSelected = true;
+ if (selectState) {
+ // Already selected and we want to keep it that way.
+ break;
+ }
+ if (start == index && end == index + 1) {
+ // A B C [ D ] E F G
+ // start^ ^end
+ // ^index
+ //
+ // Remove the range entirely.
+ this.#ranges.splice(i, 1);
+ } else if (start == index) {
+ // A [ B C D E F ] G
+ // ^start end^
+ // ^index
+ //
+ // Remove the start of the range.
+ this.#ranges[i].start = index + 1;
+ } else if (end == index + 1) {
+ // A [ B C D E F ] G
+ // ^start end^
+ // ^index
+ //
+ // Remove the end of the range.
+ this.#ranges[i].end = index;
+ } else {
+ // A [ B C D E F ] G
+ // ^start end^
+ // ^index
+ //
+ // Split the range in two.
+ //
+ // A [ B C ] D [ E F ] G
+ this.#ranges[i].end = index;
+ this.#ranges.splice(i + 1, 0, { start: index + 1, end });
+ }
+ break;
+ }
+ }
+ if (!wasSelected && (selectState == undefined || selectState)) {
+ // The index i points to a *gap* between existing ranges, so lies in
+ // [0, numItems]. Note, the space between the start and the first range,
+ // or the end and the last range count as gaps, even if they are zero
+ // width.
+ // We want to know whether the index touches the borders of the range
+ // either side of the gap.
+ let touchesRangeEnd = i > 0 && index == this.#ranges[i - 1].end;
+ // A [ B C D ] E F G H I
+ // end(i-1)^
+ // ^index
+ let touchesRangeStart =
+ i < this.#ranges.length && index + 1 == this.#ranges[i].start;
+ // A B C D E [ F G H ] I
+ // ^start(i)
+ // ^index
+ if (touchesRangeEnd && touchesRangeStart) {
+ // A [ B C D ] E [ F G H ] I
+ // ^index
+ // Merge the two ranges together.
+ this.#ranges[i - 1].end = this.#ranges[i].end;
+ this.#ranges.splice(i, 1);
+ } else if (touchesRangeEnd) {
+ // Grow the range forwards to include the index.
+ this.#ranges[i - 1].end = index + 1;
+ } else if (touchesRangeStart) {
+ // Grow the range backwards to include the index.
+ this.#ranges[i].start = index;
+ } else {
+ // Create a new range.
+ this.#ranges.splice(i, 0, { start: index, end: index + 1 });
+ }
+ }
+ this.#methods.setItemSelectionState(index, 1, selectState ?? !wasSelected);
+ // Cancel any shift range.
+ this.#shiftRangeDirection = null;
+ }
+
+ /**
+ * Determine whether the focus lies within the widget or elsewhere.
+ *
+ * @returns {boolean} - Whether the active element is the widget or one of its
+ * descendants.
+ */
+ #focusInWidget() {
+ return this.#widget.contains(this.#widget.ownerDocument.activeElement);
+ }
+
+ /**
+ * Make the specified element focusable. Also move focus to this element if
+ * the widget already has focus.
+ *
+ * @param {?number} index - The index of the item to focus, or null to focus
+ * the widget. If the index is out of range, it will be truncated.
+ * @param {boolean} [forceInWidget] - Whether the focus was in the widget
+ * before the specified element becomes focusable. This should be given to
+ * reference an earlier focus state, otherwise leave undefined to use the
+ * current focus state.
+ */
+ #moveFocus(index, focusInWidget) {
+ let numItems = this.#numItems;
+ if (index != null) {
+ if (index >= numItems) {
+ index = numItems ? numItems - 1 : null;
+ } else if (index < 0) {
+ index = numItems ? 0 : null;
+ }
+ }
+ if (focusInWidget == undefined) {
+ focusInWidget = this.#focusInWidget();
+ }
+
+ this.#focusIndex = index;
+ // If focus is within the widget, we move focus onto the new item.
+ this.#methods.setFocusableItem(index, focusInWidget);
+ }
+
+ #handleFocusIn(event) {
+ if (
+ // No item is focused,
+ this.#focusIndex == null &&
+ // and we have at least one item,
+ this.#numItems &&
+ // and the focus moved from outside the widget.
+ // NOTE: relatedTarget may be null, but Node.contains will also return
+ // false for this case, as desired.
+ !this.#widget.contains(event.relatedTarget)
+ ) {
+ // If nothing is selected, select the first item.
+ if (!this.#ranges.length) {
+ this.#selectSingle(0);
+ }
+ // Focus first selected item.
+ this.#moveFocus(this.#ranges[0].start);
+ return;
+ }
+ if (this.#focusIndex != this.#methods.indexFromTarget(event.target)) {
+ // Restore focus to where it needs to be.
+ this.#moveFocus(this.#focusIndex);
+ }
+ }
+
+ /**
+ * Adjust the focus and selection in response to a user generated event.
+ *
+ * @param {?number} [focusIndex] - The new index to move focus to, or null to
+ * move the focus to the widget, or undefined to leave the focus as it is.
+ * Note that the focusIndex will be clamped to lie within the current index
+ * range.
+ * @param {string} [select] - The change in selection to trigger, relative to
+ * the #focusIndex. "single" to select the #focusIndex, "toggle" to swap its
+ * selection state, "range" to start or continue a range selection, or "all"
+ * to select all items.
+ */
+ #adjustFocusAndSelection(focusIndex, select) {
+ let prevFocusIndex = this.#focusIndex;
+ if (focusIndex !== undefined) {
+ // NOTE: We need a strict inequality since focusIndex may be null.
+ this.#moveFocus(focusIndex);
+ }
+ // Change selection relative to the focused index.
+ // NOTE: We use the #focusIndex value rather than the focusIndex variable.
+ if (this.#focusIndex != null) {
+ switch (select) {
+ case "single":
+ this.#selectSingle(this.#focusIndex);
+ break;
+ case "toggle":
+ this.#toggleSelection(this.#focusIndex);
+ break;
+ case "range":
+ // We want to select all items between a "pivot" point and the focused
+ // index. If we do not have a "pivot" point, we use the previously
+ // focused index.
+ // This "pivot" point is lost every time the user performs a single
+ // selection or a toggle selection. I.e. if the selection changes by
+ // any means other than "range" selection.
+ //
+ // NOTE: We represent the presence of such a "pivot" point using the
+ // #shiftRangeDirection property. If it is null, no such point exists,
+ // if it is "forward" then the "pivot" point is the first selected
+ // index, and if it is "backward" then the "pivot" point is the last
+ // selected index.
+ // Usually, we only have one #ranges entry whilst doing such a Shift
+ // selection, but if items are added in the middle of such a range,
+ // then the selection can be split, but subsequent Shift selection
+ // will reselect all of them.
+ // NOTE: We do not keep track of this "pivot" index explicitly in a
+ // property because otherwise we would have to adjust its value every
+ // time items are removed, and handle cases where the "pivot" index is
+ // removed. Instead, we just borrow the logic of how the #ranges array
+ // is updated, and continue to derive the "pivot" point from the
+ // #shiftRangeDirection and #ranges properties.
+ let start;
+ switch (this.#shiftRangeDirection) {
+ case "forward":
+ // When selecting forward, the range start is the first selected
+ // index.
+ start = this.#ranges[0].start;
+ break;
+ case "backward":
+ // When selecting backward, the range end is the last selected
+ // index.
+ start = this.#ranges[this.#ranges.length - 1].end - 1;
+ break;
+ default:
+ // We start a new range selection between the previously focused
+ // index and the newly focused index.
+ start = prevFocusIndex || 0;
+ break;
+ }
+ let number;
+ // NOTE: Selection may transition from "forward" to "backward" if the
+ // user moves the selection in the other direction.
+ if (start > this.#focusIndex) {
+ this.#shiftRangeDirection = "backward";
+ number = start - this.#focusIndex + 1;
+ start = this.#focusIndex;
+ } else {
+ this.#shiftRangeDirection = "forward";
+ number = this.#focusIndex - start + 1;
+ }
+ this.#selectRange(start, number);
+ break;
+ }
+ }
+
+ // Selecting all does not require focus.
+ if (select == "all" && this.#numItems) {
+ this.#shiftRangeDirection = null;
+ this.#selectRange(0, this.#numItems);
+ }
+ }
+
+ #handleMouseDown(event) {
+ // NOTE: The default handler for mousedown will move focus onto the clicked
+ // item or the widget, but #handleFocusIn will re-assign it to the current
+ // #focusIndex if it differs.
+ if (event.button != 0 || event.metaKey || event.altKey) {
+ return;
+ }
+ let { shiftKey, ctrlKey } = event;
+ if (
+ (ctrlKey && shiftKey) ||
+ // Both modifiers pressed.
+ ((ctrlKey || shiftKey) && !this.#multiSelectable)
+ // Attempting multi-selection when not supported
+ ) {
+ return;
+ }
+ let clickIndex = this.#methods.indexFromTarget(event.target);
+ if (clickIndex == null) {
+ // Clicked empty space.
+ return;
+ }
+ if (ctrlKey) {
+ this.#adjustFocusAndSelection(clickIndex, "toggle");
+ } else if (shiftKey) {
+ this.#adjustFocusAndSelection(clickIndex, "range");
+ } else if (this.#multiSelectable && this.itemIsSelected(clickIndex)) {
+ // We set the focus now, but wait until "click" to select a single item.
+ // We do this to allow the user to drag a multi selection.
+ this.#adjustFocusAndSelection(clickIndex, undefined);
+ } else {
+ this.#adjustFocusAndSelection(clickIndex, "single");
+ }
+ }
+
+ #handleClick(event) {
+ // NOTE: This handler is only used if we have #multiSelectable.
+ // See #handleMouseDown
+ if (
+ event.button != 0 ||
+ event.metaKey ||
+ event.altKey ||
+ event.shiftKey ||
+ event.ctrlKey
+ ) {
+ return;
+ }
+ let clickIndex = this.#methods.indexFromTarget(event.target);
+ if (clickIndex == null) {
+ return;
+ }
+ this.#adjustFocusAndSelection(clickIndex, "single");
+ }
+
+ #handleKeyDown(event) {
+ if (event.altKey) {
+ // Not handled.
+ return;
+ }
+
+ let { shiftKey, ctrlKey, metaKey } = event;
+ if (
+ this.#multiSelectable &&
+ event.key == "a" &&
+ !shiftKey &&
+ (AppConstants.platform == "macosx") == metaKey &&
+ (AppConstants.platform != "macosx") == ctrlKey
+ ) {
+ this.#adjustFocusAndSelection(undefined, "all");
+ event.stopPropagation();
+ event.preventDefault();
+ return;
+ }
+
+ if (metaKey) {
+ // Not handled.
+ return;
+ }
+
+ if (event.key == " ") {
+ // Always reserve the Space press.
+ event.stopPropagation();
+ event.preventDefault();
+
+ if (shiftKey) {
+ // Not handled.
+ return;
+ }
+
+ if (ctrlKey) {
+ if (this.#multiSelectable) {
+ this.#adjustFocusAndSelection(undefined, "toggle");
+ }
+ // Else, do nothing.
+ return;
+ }
+
+ this.#adjustFocusAndSelection(undefined, "single");
+ return;
+ }
+
+ let forwardKey;
+ let backwardKey;
+ if (this.#methods.getLayoutDirection() == "vertical") {
+ forwardKey = "ArrowDown";
+ backwardKey = "ArrowUp";
+ } else if (this.#widget.matches(":dir(ltr)")) {
+ forwardKey = "ArrowRight";
+ backwardKey = "ArrowLeft";
+ } else {
+ forwardKey = "ArrowLeft";
+ backwardKey = "ArrowRight";
+ }
+
+ // NOTE: focusIndex may be set to an out of range index, but it will be
+ // clipped in #moveFocus.
+ let focusIndex;
+ switch (event.key) {
+ case "Home":
+ focusIndex = 0;
+ break;
+ case "End":
+ focusIndex = this.#numItems - 1;
+ break;
+ case "PageUp":
+ case "PageDown":
+ let sizeDetails = this.#methods.getPageSizeDetails();
+ if (!sizeDetails) {
+ // Do not handle and allow PageUp or PageDown to propagate.
+ return;
+ }
+ if (!sizeDetails.itemSize || !sizeDetails.viewSize) {
+ // Still reserve PageUp and PageDown
+ break;
+ }
+ let { itemSize, viewSize, viewOffset } = sizeDetails;
+ // We want to determine what items are visible. We count an item as
+ // "visible" if more than half of it is in view.
+ //
+ // Consider an item at index i that follows the assumed model:
+ //
+ // [ item content ]
+ // <---- itemSize ---->
+ // ---->start_i = i * itemSize
+ //
+ // where start_i is the offset of the starting edge of the item relative
+ // to the starting edge of the first item.
+ //
+ // As such, an item will be visible if
+ // start_i + itemSize / 2 > viewOffset
+ // and
+ // start_i + itemSize / 2 < viewOffset + viewSize
+ // <=>
+ // i > (viewOffset / itemSize) - 1/2
+ // and
+ // i < ((viewOffset + viewSize) / itemSize) - 1/2
+
+ // First, we want to know the number of items we can visibly fit on a
+ // page. I.e. when the viewOffset is 0, the number of items whose midway
+ // point is lower than the viewSize. This is given by (i + 1), where i
+ // is the largest index i that satisfies
+ // i < (viewSize / itemSize) - 1/2
+ // This is given by taking the ceiling - 1, which cancels with the +1.
+ let itemsPerPage = Math.ceil(viewSize / itemSize - 0.5);
+ if (itemsPerPage <= 1) {
+ break;
+ }
+ if (event.key == "PageUp") {
+ // We want to know what the first visible index is. I.e. the smallest
+ // i that satisfies
+ // i > (viewOffset / itemSize) - 1/2
+ // This is equivalent to flooring the right hand side + 1.
+ let pageStart = Math.floor(viewOffset / itemSize - 0.5) + 1;
+ if (this.#focusIndex == null || this.#focusIndex > pageStart) {
+ // Move focus to the top of the page.
+ focusIndex = pageStart;
+ } else {
+ // Reduce focusIndex by one page.
+ // We add "1" index to try and keep the previous focusIndex visible
+ // at the bottom of the view.
+ focusIndex = this.#focusIndex - itemsPerPage + 1;
+ }
+ } else {
+ // We want to know what the last visible index is. I.e. the largest i
+ // that satisfies
+ // i < (viewOffset + viewSize) / itemSize - 1/2
+ // This is equivalent to ceiling the right hand side - 1.
+ let pageEnd = Math.ceil((viewOffset + viewSize) / itemSize - 0.5) - 1;
+ if (this.#focusIndex == null || this.#focusIndex < pageEnd) {
+ // Move focus to the end of the page.
+ focusIndex = pageEnd;
+ } else {
+ // Increase focusIndex by one page.
+ // We minus "1" index to try and keep the previous focusIndex
+ // visible at the top of the view.
+ focusIndex = this.#focusIndex + itemsPerPage - 1;
+ }
+ }
+ break;
+ case forwardKey:
+ if (this.#focusIndex == null) {
+ // Move to first item.
+ focusIndex = 0;
+ } else {
+ focusIndex = this.#focusIndex + 1;
+ }
+ break;
+ case backwardKey:
+ if (this.#focusIndex == null) {
+ // Move to first item.
+ focusIndex = 0;
+ } else {
+ focusIndex = this.#focusIndex - 1;
+ }
+ break;
+ default:
+ // Not a navigation key.
+ return;
+ }
+
+ // NOTE: We always reserve control over these keys, regardless of whether
+ // we respond to them.
+ event.stopPropagation();
+ event.preventDefault();
+
+ if (focusIndex === undefined) {
+ return;
+ }
+
+ if (shiftKey && ctrlKey) {
+ // Both modifiers not handled.
+ return;
+ }
+
+ if (ctrlKey) {
+ // Move the focus without changing the selection.
+ if (!this.#focusIsSelected) {
+ this.#adjustFocusAndSelection(focusIndex, undefined);
+ }
+ return;
+ }
+
+ if (shiftKey) {
+ // Range selection.
+ if (this.#multiSelectable) {
+ this.#adjustFocusAndSelection(focusIndex, "range");
+ }
+ return;
+ }
+
+ this.#adjustFocusAndSelection(focusIndex, "single");
+ }
+}