/* 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/. */ /** * This implementation attempts to mimic the behavior of nsTreeSelection. In * a few cases, this leads to potentially confusing actions. I attempt to note * when we are doing this and why we do it. * * Unit test is in mail/base/test/unit/test_treeSelection.js */ export class TreeSelection { QueryInterface = ChromeUtils.generateQI(["nsITreeSelection"]); /** * The current XULTreeElement, appropriately QueryInterfaced. May be null. */ _tree; /** * Where the focus rectangle (that little dotted thing) shows up. Just * because something is focused does not mean it is actually selected. */ _currentIndex; /** * The view index where the shift is anchored when it is not (conceptually) * the same as _currentIndex. This only happens when you perform a ranged * selection. In that case, the start index of the ranged selection becomes * the shift pivot (and the _currentIndex becomes the end of the ranged * selection.) * It gets cleared whenever the selection changes and it's not the result of * a call to rangedSelect. */ _shiftSelectPivot; /** * A list of [lowIndexInclusive, highIndexInclusive] non-overlapping, * non-adjacent 'tuples' sort in ascending order. */ _ranges; /** * The number of currently selected rows. */ _count; // In the case of the stand-alone message window, there's no tree, but // there's a view. _view; /** * A set of indices we think is invalid. */ _invalidIndices; constructor(tree) { this._tree = tree; this._currentIndex = null; this._shiftSelectPivot = null; this._ranges = []; this._count = 0; this._invalidIndices = new Set(); this._selectEventsSuppressed = false; } /** * Mark the currently selected rows as invalid. */ _invalidateSelection() { for (let [low, high] of this._ranges) { for (let i = low; i <= high; i++) { this._invalidIndices.add(i); } } } /** * Call `invalidateRow` on the tree for each row we think is invalid. */ _doInvalidateRows() { if (this.selectEventsSuppressed) { return; } if (this._tree) { for (let i of this._invalidIndices) { this._tree.invalidateRow(i); } } this._invalidIndices.clear(); } /** * Call `invalidateRange` on the tree. * * @param {number} startIndex - The first index to invalidate. * @param {number?} endIndex - The last index to invalidate. If not given, * defaults to the index of the last row. */ _doInvalidateRange(startIndex, endIndex) { let noEndIndex = endIndex === undefined; if (noEndIndex) { if (!this._view || this.view.rowCount == 0) { this._doInvalidateAll(); return; } endIndex = this._view.rowCount - 1; } if (this._tree) { this._tree.invalidateRange(startIndex, endIndex); } for (let i of this._invalidIndices) { if (i >= startIndex && (noEndIndex || i <= endIndex)) { this._invalidIndices.delete(i); } } } /** * Call `invalidate` on the tree. */ _doInvalidateAll() { if (this._tree) { this._tree.invalidate(); } this._invalidIndices.clear(); } get tree() { return this._tree; } set tree(tree) { this._tree = tree; } get view() { return this._view; } set view(view) { this._view = view; } /** * Although the nsITreeSelection documentation doesn't say, what this method * is supposed to do is check if the seltype attribute on the XUL tree is any * of the following: "single" (only a single row may be selected at a time, * "cell" (a single cell may be selected), or "text" (the row gets selected * but only the primary column shows up as selected.) * * @returns false because we don't support single-selection. */ get single() { return false; } _updateCount() { this._count = 0; for (let [low, high] of this._ranges) { this._count += high - low + 1; } } get count() { return this._count; } isSelected(viewIndex) { for (let [low, high] of this._ranges) { if (viewIndex >= low && viewIndex <= high) { return true; } } return false; } /** * Select the given row. It does nothing if that row was already selected. */ select(viewIndex) { this._invalidateSelection(); // current index will provide our effective shift pivot this._shiftSelectPivot = null; this._currentIndex = viewIndex != -1 ? viewIndex : null; if (this._count == 1 && this._ranges[0][0] == viewIndex) { return; } if (viewIndex >= 0) { this._count = 1; this._ranges = [[viewIndex, viewIndex]]; this._invalidIndices.add(viewIndex); } else { this._count = 0; this._ranges = []; } this._doInvalidateRows(); this._fireSelectionChanged(); } timedSelect(index, delay) { throw new Error("We do not implement timed selection."); } toggleSelect(index) { this._currentIndex = index; // If nothing's selected, select index if (this._count == 0) { this._count = 1; this._ranges = [[index, index]]; } else { let added = false; for (let [iTupe, [low, high]] of this._ranges.entries()) { // below the range? add it to the existing range or create a new one if (index < low) { this._count++; // is it just below an existing range? (range fusion only happens in the // high case, not here.) if (index == low - 1) { this._ranges[iTupe][0] = index; added = true; break; } // then it gets its own range this._ranges.splice(iTupe, 0, [index, index]); added = true; break; } // in the range? will need to either nuke, shrink, or split the range to // remove it if (index >= low && index <= high) { this._count--; if (index == low && index == high) { // nuke this._ranges.splice(iTupe, 1); } else if (index == low) { // lower shrink this._ranges[iTupe][0] = index + 1; } else if (index == high) { // upper shrink this._ranges[iTupe][1] = index - 1; } else { // split this._ranges.splice(iTupe, 1, [low, index - 1], [index + 1, high]); } added = true; break; } // just above the range? fuse into the range, and possibly the next // range up. if (index == high + 1) { this._count++; // see if there is another range and there was just a gap of one between // the two ranges. if ( iTupe + 1 < this._ranges.length && this._ranges[iTupe + 1][0] == index + 1 ) { // yes, merge the ranges this._ranges.splice(iTupe, 2, [low, this._ranges[iTupe + 1][1]]); added = true; break; } // nope, no merge required, just update the range this._ranges[iTupe][1] = index; added = true; break; } // otherwise we need to keep going } if (!added) { this._count++; this._ranges.push([index, index]); } } this._invalidIndices.add(index); this._doInvalidateRows(); this._fireSelectionChanged(); } /** * @param rangeStart If omitted, it implies a shift-selection is happening, * in which case we use _shiftSelectPivot as the start if we have it, * _currentIndex if we don't, and if we somehow didn't have a * _currentIndex, we use the range end. * @param rangeEnd Just the inclusive end of the range. * @param augment Does this set a new selection or should it be merged with * the existing selection? */ rangedSelect(rangeStart, rangeEnd, augment) { if (rangeStart == -1) { if (this._shiftSelectPivot != null) { rangeStart = this._shiftSelectPivot; } else if (this._currentIndex != null) { rangeStart = this._currentIndex; } else { rangeStart = rangeEnd; } } this._shiftSelectPivot = rangeStart; this._currentIndex = rangeEnd; // enforce our ordering constraint for our ranges if (rangeStart > rangeEnd) { [rangeStart, rangeEnd] = [rangeEnd, rangeStart]; } // if we're not augmenting, then this is really easy. if (!augment) { this._invalidateSelection(); this._count = rangeEnd - rangeStart + 1; this._ranges = [[rangeStart, rangeEnd]]; for (let i = rangeStart; i <= rangeEnd; i++) { this._invalidIndices.add(i); } this._doInvalidateRows(); this._fireSelectionChanged(); return; } // Iterate over our existing set of ranges, finding the 'range' of ranges // that our new range overlaps or simply obviates. // Overlap variables track blocks we need to keep some part of, Nuke // variables are for blocks that get spliced out. For our purposes, all // overlap blocks are also nuke blocks. let lowOverlap, lowNuke, highNuke, highOverlap; // in case there is no overlap, also figure an insertionPoint let insertionPoint = this._ranges.length; // default to the end for (let [iTupe, [low, high]] of this._ranges.entries()) { // If it's completely include the range, it should be nuked if (rangeStart <= low && rangeEnd >= high) { if (lowNuke == null) { // only the first one we see is the low one lowNuke = iTupe; } highNuke = iTupe; } // If our new range start is inside a range or is adjacent, it's overlap if ( rangeStart >= low - 1 && rangeStart <= high + 1 && lowOverlap == null ) { lowOverlap = lowNuke = highNuke = iTupe; } // If our new range ends inside a range or is adjacent, it's overlap if (rangeEnd >= low - 1 && rangeEnd <= high + 1) { highOverlap = highNuke = iTupe; if (lowNuke == null) { lowNuke = iTupe; } } // we're done when no more overlap is possible if (rangeEnd < low) { insertionPoint = iTupe; break; } } if (lowOverlap != null) { rangeStart = Math.min(rangeStart, this._ranges[lowOverlap][0]); } if (highOverlap != null) { rangeEnd = Math.max(rangeEnd, this._ranges[highOverlap][1]); } if (lowNuke != null) { this._ranges.splice(lowNuke, highNuke - lowNuke + 1, [ rangeStart, rangeEnd, ]); } else { this._ranges.splice(insertionPoint, 0, [rangeStart, rangeEnd]); } for (let i = rangeStart; i <= rangeEnd; i++) { this._invalidIndices.add(i); } this._updateCount(); this._doInvalidateRows(); this._fireSelectionChanged(); } /** * This is basically RangedSelect but without insertion of a new range and we * don't need to worry about adjacency. * Oddly, nsTreeSelection doesn't fire a selection changed event here... */ clearRange(rangeStart, rangeEnd) { // Iterate over our existing set of ranges, finding the 'range' of ranges // that our clear range overlaps or simply obviates. // Overlap variables track blocks we need to keep some part of, Nuke // variables are for blocks that get spliced out. For our purposes, all // overlap blocks are also nuke blocks. let lowOverlap, lowNuke, highNuke, highOverlap; for (let [iTupe, [low, high]] of this._ranges.entries()) { // If we completely include the range, it should be nuked if (rangeStart <= low && rangeEnd >= high) { if (lowNuke == null) { // only the first one we see is the low one lowNuke = iTupe; } highNuke = iTupe; } // If our new range start is inside a range, it's nuke and maybe overlap if (rangeStart >= low && rangeStart <= high && lowNuke == null) { lowNuke = highNuke = iTupe; // it's only overlap if we don't match at the low end if (rangeStart > low) { lowOverlap = iTupe; } } // If our new range ends inside a range, it's nuke and maybe overlap if (rangeEnd >= low && rangeEnd <= high) { highNuke = iTupe; // it's only overlap if we don't match at the high end if (rangeEnd < high) { highOverlap = iTupe; } if (lowNuke == null) { lowNuke = iTupe; } } // we're done when no more overlap is possible if (rangeEnd < low) { break; } } // nothing to do since there's nothing to nuke if (lowNuke == null) { return; } let args = [lowNuke, highNuke - lowNuke + 1]; if (lowOverlap != null) { args.push([this._ranges[lowOverlap][0], rangeStart - 1]); } if (highOverlap != null) { args.push([rangeEnd + 1, this._ranges[highOverlap][1]]); } this._ranges.splice.apply(this._ranges, args); for (let i = rangeStart; i <= rangeEnd; i++) { this._invalidIndices.add(i); } this._updateCount(); this._doInvalidateRows(); // note! nsTreeSelection doesn't fire a selection changed event, so neither // do we, but it seems like we should } /** * nsTreeSelection always fires a select notification when the range is * cleared, even if there is no effective chance in selection. */ clearSelection() { this._invalidateSelection(); this._shiftSelectPivot = null; this._count = 0; this._ranges = []; this._doInvalidateRows(); this._fireSelectionChanged(); } /** * Select all with no rows is a no-op, otherwise we select all and notify. */ selectAll() { if (!this._view) { return; } let view = this._view; let rowCount = view.rowCount; // no-ops-ville if (!rowCount) { return; } this._count = rowCount; this._ranges = [[0, rowCount - 1]]; this._doInvalidateAll(); this._fireSelectionChanged(); } getRangeCount() { return this._ranges.length; } getRangeAt(rangeIndex, minObj, maxObj) { if (rangeIndex < 0 || rangeIndex >= this._ranges.length) { throw new Error("Try a real range index next time."); } [minObj.value, maxObj.value] = this._ranges[rangeIndex]; } /** * Helper method to adjust points in the face of row additions/removal. * * @param point The point, null if there isn't one, or an index otherwise. * @param deltaAt The row at which the change is happening. * @param delta The number of rows added if positive, or the (negative) * number of rows removed. */ _adjustPoint(point, deltaAt, delta) { // if there is no point, no change if (point == null) { return point; } // if the point is before the change, no change if (point < deltaAt) { return point; } // if it's a deletion and it includes the point, clear it if (delta < 0 && point >= deltaAt && point + delta < deltaAt) { return null; } // (else) the point is at/after the change, compensate return point + delta; } /** * Find the index of the range, if any, that contains the given index, and * the index at which to insert a range if one does not exist. * * @returns A tuple containing: 1) the index if there is one, null otherwise, * 2) the index at which to insert a range that would contain the point. */ _findRangeContainingRow(index) { for (let [iTupe, [low, high]] of this._ranges.entries()) { if (index >= low && index <= high) { return [iTupe, iTupe]; } if (index < low) { return [null, iTupe]; } } return [null, this._ranges.length]; } /** * When present, a list of calls made to adjustSelection. See * |logAdjustSelectionForReplay| and |replayAdjustSelectionLog|. */ _adjustSelectionLog = null; /** * Start logging calls to adjustSelection made against this instance. You * would do this because you are replacing an existing selection object * with this instance for the purposes of creating a transient selection. * Of course, you want the original selection object to be up-to-date when * you go to put it back, so then you can call replayAdjustSelectionLog * with that selection object and everything will be peachy. */ logAdjustSelectionForReplay() { this._adjustSelectionLog = []; } /** * Stop logging calls to adjustSelection and replay the existing log against * selection. * * @param selection {nsITreeSelection}. */ replayAdjustSelectionLog(selection) { if (this._adjustSelectionLog.length) { // Temporarily disable selection events because adjustSelection is going // to generate an event each time otherwise, and better 1 event than // many. selection.selectEventsSuppressed = true; for (let [index, count] of this._adjustSelectionLog) { selection.adjustSelection(index, count); } selection.selectEventsSuppressed = false; } this._adjustSelectionLog = null; } adjustSelection(index, count) { // nothing to do if there is no actual change if (!count) { return; } if (this._adjustSelectionLog) { this._adjustSelectionLog.push([index, count]); } // adjust our points this._shiftSelectPivot = this._adjustPoint( this._shiftSelectPivot, index, count ); this._currentIndex = this._adjustPoint(this._currentIndex, index, count); // If we are adding rows, we want to split any range at index and then // translate all of the ranges above that point up. if (count > 0) { let [iContain, iInsert] = this._findRangeContainingRow(index); if (iContain != null) { let [low, high] = this._ranges[iContain]; // if it is the low value, we just want to shift the range entirely, so // do nothing (and keep iInsert pointing at it for translation) // if it is not the low value, then there must be at least two values so // we should split it and only translate the new/upper block if (index != low) { this._ranges.splice(iContain, 1, [low, index - 1], [index, high]); iInsert++; } } // now translate everything from iInsert on up for (let iTrans = iInsert; iTrans < this._ranges.length; iTrans++) { let [low, high] = this._ranges[iTrans]; this._ranges[iTrans] = [low + count, high + count]; } // invalidate and fire selection change notice this._doInvalidateRange(index); this._fireSelectionChanged(); return; } // If we are removing rows, we are basically clearing the range that is // getting deleted and translating everyone above the remaining point // downwards. The one trick is we may have to merge the lowest translated // block. let saveSuppress = this.selectEventsSuppressed; this.selectEventsSuppressed = true; this.clearRange(index, index - count - 1); // translate let iTrans = this._findRangeContainingRow(index)[1]; for (; iTrans < this._ranges.length; iTrans++) { let [low, high] = this._ranges[iTrans]; // for the first range, low may be below the index, in which case it // should not get translated this._ranges[iTrans] = [low >= index ? low + count : low, high + count]; } // we may have to merge the lowest translated block because it may now be // adjacent to the previous block if ( iTrans > 0 && iTrans < this._ranges.length && this._ranges[iTrans - 1][1] == this._ranges[iTrans][0] ) { this._ranges[iTrans - 1][1] = this._ranges[iTrans][1]; this._ranges.splice(iTrans, 1); } this._doInvalidateRange(index); this.selectEventsSuppressed = saveSuppress; } get selectEventsSuppressed() { return this._selectEventsSuppressed; } /** * Control whether selection events are suppressed. For consistency with * nsTreeSelection, we always generate a selection event when a value of * false is assigned, even if the value was already false. */ set selectEventsSuppressed(suppress) { if (this._selectEventsSuppressed == suppress) { return; } this._selectEventsSuppressed = suppress; if (!suppress) { this._fireSelectionChanged(); } } /** * Note that we bypass any XUL "onselect" handler that may exist and go * straight to the view. If you have a tree, you shouldn't be using us, * so this seems aboot right. */ _fireSelectionChanged() { // don't fire if we are suppressed; we will fire when un-suppressed if (this.selectEventsSuppressed) { return; } let view = this._tree?.view ?? this._view; // We might not have a view if we're in the middle of setting up things view?.selectionChanged(); } get currentIndex() { if (this._currentIndex == null) { return -1; } return this._currentIndex; } /** * Sets the current index. Other than updating the variable, this just * invalidates the tree row if we have a tree. * The real selection object would send a DOM event we don't care about. */ set currentIndex(index) { if (index == this.currentIndex) { return; } this._invalidateSelection(); this._currentIndex = index != -1 ? index : null; this._invalidIndices.add(index); this._doInvalidateRows(); } get shiftSelectPivot() { return this._shiftSelectPivot != null ? this._shiftSelectPivot : -1; } /* * Functions after this aren't part of the nsITreeSelection interface. */ /** * Duplicate this selection on another nsITreeSelection. This is useful * when you would like to discard this selection for a real tree selection. * We assume that both selections are for the same tree. * * @note We don't transfer the correct shiftSelectPivot over. * @note This will fire a selectionChanged event on the tree view. * * @param selection an nsITreeSelection to duplicate this selection onto */ duplicateSelection(selection) { selection.selectEventsSuppressed = true; selection.clearSelection(); for (let [iTupe, [low, high]] of this._ranges.entries()) { selection.rangedSelect(low, high, iTupe > 0); } selection.currentIndex = this.currentIndex; // This will fire a selectionChanged event selection.selectEventsSuppressed = false; } }