summaryrefslogtreecommitdiffstats
path: root/mailnews/base/util/jsTreeSelection.js
diff options
context:
space:
mode:
Diffstat (limited to 'mailnews/base/util/jsTreeSelection.js')
-rw-r--r--mailnews/base/util/jsTreeSelection.js654
1 files changed, 654 insertions, 0 deletions
diff --git a/mailnews/base/util/jsTreeSelection.js b/mailnews/base/util/jsTreeSelection.js
new file mode 100644
index 000000000..0ba5ea42c
--- /dev/null
+++ b/mailnews/base/util/jsTreeSelection.js
@@ -0,0 +1,654 @@
+/* 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.EXPORTED_SYMBOLS = ['JSTreeSelection'];
+
+var Cc = Components.classes;
+var Ci = Components.interfaces;
+var Cr = Components.results;
+var Cu = Components.utils;
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+/**
+ * Partial nsITreeSelection implementation so that we can have nsMsgDBViews that
+ * exist only for message display but do not need to be backed by a full
+ * tree view widget. This could also hopefully be used for more xpcshell unit
+ * testing of the FolderDisplayWidget. It might also be useful for creating
+ * transient selections when right-click selection happens.
+ *
+ * Our current limitations:
+ * - We do not support any single selection modes. This is mainly because we
+ * need to look at the box object for that and we don't want to do it.
+ * - Timed selection. Our expected consumers don't use it.
+ *
+ * Our current laziness:
+ * - We aren't very precise about invalidation when it would be potentially
+ * complicated. The theory is that if there is a tree box object, it's
+ * probably native and the XPConnect overhead is probably a lot more than
+ * any potential savings, at least for now when the tree display is
+ * generally C++ XPCOM backed rather than JS XPCOM backed. Also, we
+ * aren't intended to actually be used with a real tree display; you should
+ * be using the C++ object in that case!
+ *
+ * If documentation is omitted for something, it is because we have little to
+ * add to the documentation of nsITreeSelection and really hope that our
+ * documentation tool will copy-down that documentation.
+ *
+ * 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 mailnews/base/util/test_jsTreeSelection.js
+ */
+function JSTreeSelection(aTreeBoxObject) {
+ this._treeBoxObject = aTreeBoxObject;
+
+ this._currentIndex = null;
+ this._shiftSelectPivot = null;
+ this._ranges = [];
+ this._count = 0;
+
+ this._selectEventsSuppressed = false;
+}
+JSTreeSelection.prototype = {
+ /**
+ * The current nsITreeBoxObject, appropriately QueryInterfaced. May be null.
+ */
+ _treeBoxObject: null,
+
+ /**
+ * Where the focus rectangle (that little dotted thing) shows up. Just
+ * because something is focused does not mean it is actually selected.
+ */
+ _currentIndex: null,
+ /**
+ * 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: null,
+ /**
+ * A list of [lowIndexInclusive, highIndexInclusive] non-overlapping,
+ * non-adjacent 'tuples' sort in ascending order.
+ */
+ _ranges: [],
+ /**
+ * The number of currently selected rows.
+ */
+ _count: 0,
+
+ // In the case of the stand-alone message window, there's no tree, but
+ // there's a view.
+ _view: null,
+
+ get tree() {
+ return this._treeBoxObject;
+ },
+ set tree(aTreeBoxObject) {
+ this._treeBoxObject = aTreeBoxObject;
+ },
+
+ set view(aView) {
+ this._view = aView;
+ },
+ /**
+ * 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.)
+ *
+ * @return false because we don't support single-selection.
+ */
+ get single() {
+ return false;
+ },
+
+ _updateCount: function JSTreeSelection__updateCount() {
+ this._count = 0;
+ for (let [low, high] of this._ranges) {
+ this._count += high - low + 1;
+ }
+ },
+
+ get count() {
+ return this._count;
+ },
+
+ isSelected: function JSTreeSelection_isSelected(aViewIndex) {
+ for (let [low, high] of this._ranges) {
+ if (aViewIndex >= low && aViewIndex <= high)
+ return true;
+ }
+ return false;
+ },
+
+ /**
+ * Select the given row. It does nothing if that row was already selected.
+ */
+ select: function JSTreeSelection_select(aViewIndex) {
+ // current index will provide our effective shift pivot
+ this._shiftSelectPivot = null;
+ this.currentIndex = aViewIndex;
+
+ if (this._count == 1 && this._ranges[0][0] == aViewIndex)
+ return;
+
+ this._count = 1;
+ this._ranges = [[aViewIndex, aViewIndex]];
+
+ if (this._treeBoxObject)
+ this._treeBoxObject.invalidate();
+
+ this._fireSelectionChanged();
+ },
+
+ timedSelect: function JSTreeSelection_timedSelect(aIndex, aDelay) {
+ throw new Error("We do not implement timed selection.");
+ },
+
+ toggleSelect: function JSTreeSelection_toggleSelect(aIndex) {
+ this.currentIndex = aIndex;
+ // If nothing's selected, select aIndex
+ if (this._count == 0) {
+ this._count = 1;
+ this._ranges = [[aIndex, aIndex]];
+ }
+ else for (let [iTupe, [low, high]] of this._ranges.entries()) {
+ // below the range? add it to the existing range or create a new one
+ if (aIndex < low) {
+ this._count++;
+ // is it just below an existing range? (range fusion only happens in the
+ // high case, not here.)
+ if (aIndex == low - 1) {
+ this._ranges[iTupe][0] = aIndex;
+ break;
+ }
+ // then it gets its own range
+ this._ranges.splice(iTupe, 0, [aIndex, aIndex]);
+ break;
+ }
+ // in the range? will need to either nuke, shrink, or split the range to
+ // remove it
+ if (aIndex >= low && aIndex <= high) {
+ this._count--;
+ // nuke
+ if (aIndex == low && aIndex == high)
+ this._ranges.splice(iTupe, 1);
+ // lower shrink
+ else if (aIndex == low)
+ this._ranges[iTupe][0] = aIndex + 1;
+ // upper shrink
+ else if (aIndex == high)
+ this._ranges[iTupe][1] = aIndex - 1;
+ // split
+ else
+ this._ranges.splice(iTupe, 1, [low, aIndex - 1], [aIndex + 1, high]);
+ break;
+ }
+ // just above the range? fuse into the range, and possibly the next
+ // range up.
+ if (aIndex == 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] == aIndex + 1)) {
+ // yes, merge the ranges
+ this._ranges.splice(iTupe, 2, [low, this._ranges[iTupe+1][1]]);
+ break;
+ }
+ // nope, no merge required, just update the range
+ this._ranges[iTupe][1] = aIndex;
+ break;
+ }
+ // otherwise we need to keep going
+ }
+
+ if (this._treeBoxObject)
+ this._treeBoxObject.invalidateRow(aIndex);
+ this._fireSelectionChanged();
+ },
+
+ /**
+ * @param aRangeStart 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 aRangeEnd Just the inclusive end of the range.
+ * @param aAugment Does this set a new selection or should it be merged with
+ * the existing selection?
+ */
+ rangedSelect: function JSTreeSelection_rangedSelect(aRangeStart, aRangeEnd,
+ aAugment) {
+ if (aRangeStart == -1) {
+ if (this._shiftSelectPivot != null)
+ aRangeStart = this._shiftSelectPivot;
+ else if (this._currentIndex != null)
+ aRangeStart = this._currentIndex;
+ else
+ aRangeStart = aRangeEnd;
+ }
+
+ this._shiftSelectPivot = aRangeStart;
+ this.currentIndex = aRangeEnd;
+
+ // enforce our ordering constraint for our ranges
+ if (aRangeStart > aRangeEnd)
+ [aRangeStart, aRangeEnd] = [aRangeEnd, aRangeStart];
+
+ // if we're not augmenting, then this is really easy.
+ if (!aAugment) {
+ this._count = aRangeEnd - aRangeStart + 1;
+ this._ranges = [[aRangeStart, aRangeEnd]];
+ if (this._treeBoxObject)
+ this._treeBoxObject.invalidate();
+ 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 (aRangeStart <= low && aRangeEnd >= 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 (aRangeStart >= low - 1 && aRangeStart <= high + 1 &&
+ lowOverlap == null)
+ lowOverlap = lowNuke = highNuke = iTupe;
+ // If our new range ends inside a range or is adjacent, it's overlap
+ if (aRangeEnd >= low - 1 && aRangeEnd <= high + 1) {
+ highOverlap = highNuke = iTupe;
+ if (lowNuke == null)
+ lowNuke = iTupe;
+ }
+
+ // we're done when no more overlap is possible
+ if (aRangeEnd < low) {
+ insertionPoint = iTupe;
+ break;
+ }
+ }
+
+ if (lowOverlap != null)
+ aRangeStart = Math.min(aRangeStart, this._ranges[lowOverlap][0]);
+ if (highOverlap != null)
+ aRangeEnd = Math.max(aRangeEnd, this._ranges[highOverlap][1]);
+ if (lowNuke != null)
+ this._ranges.splice(lowNuke, highNuke - lowNuke + 1,
+ [aRangeStart, aRangeEnd]);
+ else
+ this._ranges.splice(insertionPoint, 0, [aRangeStart, aRangeEnd]);
+
+ this._updateCount();
+ if (this._treeBoxObject)
+ this._treeBoxObject.invalidate();
+ 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: function JSTreeSelection_clearRange(aRangeStart, aRangeEnd) {
+ // 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 (aRangeStart <= low && aRangeEnd >= 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 (aRangeStart >= low && aRangeStart <= high && lowNuke == null) {
+ lowNuke = highNuke = iTupe;
+ // it's only overlap if we don't match at the low end
+ if (aRangeStart > low)
+ lowOverlap = iTupe;
+ }
+ // If our new range ends inside a range, it's nuke and maybe overlap
+ if (aRangeEnd >= low && aRangeEnd <= high) {
+ highNuke = iTupe;
+ // it's only overlap if we don't match at the high end
+ if (aRangeEnd < high)
+ highOverlap = iTupe;
+ if (lowNuke == null)
+ lowNuke = iTupe;
+ }
+
+ // we're done when no more overlap is possible
+ if (aRangeEnd < 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], aRangeStart - 1]);
+ if (highOverlap != null)
+ args.push([aRangeEnd + 1, this._ranges[highOverlap][1]]);
+ this._ranges.splice.apply(this._ranges, args);
+
+ this._updateCount();
+ if (this._treeBoxObject)
+ this._treeBoxObject.invalidate();
+ // 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: function JSTreeSelection_clearSelection() {
+ this._shiftSelectPivot = null;
+ this._count = 0;
+ this._ranges = [];
+ if (this._treeBoxObject)
+ this._treeBoxObject.invalidate();
+ this._fireSelectionChanged();
+ },
+
+ /**
+ * Not even nsTreeSelection implements this.
+ */
+ invertSelection: function JSTreeSelection_invertSelection() {
+ throw new Error("Who really was going to use this?");
+ },
+
+ /**
+ * Select all with no rows is a no-op, otherwise we select all and notify.
+ */
+ selectAll: function JSTreeSelection_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]];
+
+ if (this._treeBoxObject)
+ this._treeBoxObject.invalidate();
+ this._fireSelectionChanged();
+ },
+
+ getRangeCount: function JSTreeSelection_getRangeCount() {
+ return this._ranges.length;
+ },
+ getRangeAt: function JSTreeSelection_getRangeAt(aRangeIndex, aMinObj,
+ aMaxObj) {
+ if (aRangeIndex < 0 || aRangeIndex > this._ranges.length)
+ throw new Exception("Try a real range index next time.");
+ [aMinObj.value, aMaxObj.value] = this._ranges[aRangeIndex];
+ },
+
+ invalidateSelection: function JSTreeSelection_invalidateSelection() {
+ if (this._treeBoxObject)
+ this._treeBoxObject.invalidate();
+ },
+
+ /**
+ * Helper method to adjust points in the face of row additions/removal.
+ * @param aPoint The point, null if there isn't one, or an index otherwise.
+ * @param aDeltaAt The row at which the change is happening.
+ * @param aDelta The number of rows added if positive, or the (negative)
+ * number of rows removed.
+ */
+ _adjustPoint: function JSTreeSelection__adjustPoint(aPoint, aDeltaAt,
+ aDelta) {
+ // if there is no point, no change
+ if (aPoint == null)
+ return aPoint;
+ // if the point is before the change, no change
+ if (aPoint < aDeltaAt)
+ return aPoint;
+ // if it's a deletion and it includes the point, clear it
+ if (aDelta < 0 && aPoint >= aDeltaAt && (aPoint + aDelta < aDeltaAt))
+ return null;
+ // (else) the point is at/after the change, compensate
+ return aPoint + aDelta;
+ },
+ /**
+ * 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.
+ *
+ * @return 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:
+ function JSTreeSelection__findRangeContainingRow(aIndex) {
+ for (let [iTupe, [low, high]] of this._ranges.entries()) {
+ if (aIndex >= low && aIndex <= high)
+ return [iTupe, iTupe];
+ if (aIndex < 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:
+ function JSTreeSelection_logAdjustSelectionForReplay() {
+ this._adjustSelectionLog = [];
+ },
+ /**
+ * Stop logging calls to adjustSelection and replay the existing log against
+ * aSelection.
+ *
+ * @param aSelection {nsITreeSelection}.
+ */
+ replayAdjustSelectionLog:
+ function JSTreeSelection_replayAdjustSelectionLog(aSelection) {
+ 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.
+ aSelection.selectEventsSuppressed = true;
+ for (let [index, count] of this._adjustSelectionLog) {
+ aSelection.adjustSelection(index, count);
+ }
+ aSelection.selectEventsSuppressed = false;
+ }
+ this._adjustSelectionLog = null;
+ },
+
+ adjustSelection: function JSTreeSelection_adjustSelection(aIndex, aCount) {
+ // nothing to do if there is no actual change
+ if (!aCount)
+ return;
+
+ if (this._adjustSelectionLog)
+ this._adjustSelectionLog.push([aIndex, aCount]);
+
+ // adjust our points
+ this._shiftSelectPivot = this._adjustPoint(this._shiftSelectPivot,
+ aIndex, aCount);
+ this._currentIndex = this._adjustPoint(this._currentIndex, aIndex, aCount);
+
+ // If we are adding rows, we want to split any range at aIndex and then
+ // translate all of the ranges above that point up.
+ if (aCount > 0) {
+ let [iContain, iInsert] = this._findRangeContainingRow(aIndex);
+ 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 (aIndex != low) {
+ this._ranges.splice(iContain, 1, [low, aIndex - 1], [aIndex, 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 + aCount, high + aCount];
+ }
+ // invalidate and fire selection change notice
+ if (this._treeBoxObject)
+ this._treeBoxObject.invalidate();
+ 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(aIndex, aIndex - aCount - 1);
+ // translate
+ let iTrans = this._findRangeContainingRow(aIndex)[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 >= aIndex) ? low + aCount : low,
+ high + aCount];
+ }
+ // 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);
+ }
+
+ if (this._treeBoxObject)
+ this._treeBoxObject.invalidate();
+ 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(aSuppress) {
+ this._selectEventsSuppressed = aSuppress;
+ if (!aSuppress)
+ 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: function JSTreeSelection__fireSelectionChanged() {
+ // don't fire if we are suppressed; we will fire when un-suppressed
+ if (this.selectEventsSuppressed)
+ return;
+ let view;
+ if (this._treeBoxObject && this._treeBoxObject.view)
+ view = this._treeBoxObject.view;
+ else
+ view = this._view;
+
+ // We might not have a view if we're in the middle of setting up things
+ if (view) {
+ view = view.QueryInterface(Ci.nsITreeView);
+ 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(aIndex) {
+ if (aIndex == this.currentIndex)
+ return;
+
+ this._currentIndex = (aIndex != -1) ? aIndex : null;
+ if (this._treeBoxObject)
+ this._treeBoxObject.invalidateRow(aIndex);
+ },
+
+ currentColumn: null,
+
+ get shiftSelectPivot() {
+ return this._shiftSelectPivot != null ? this._shiftSelectPivot : -1;
+ },
+
+ QueryInterface: XPCOMUtils.generateQI(
+ [Ci.nsITreeSelection]),
+
+ /*
+ * 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 aSelection an nsITreeSelection to duplicate this selection onto
+ */
+ duplicateSelection: function JSTreeSelection_duplicateSelection(aSelection) {
+ aSelection.selectEventsSuppressed = true;
+ aSelection.clearSelection();
+ for (let [iTupe, [low, high]] of this._ranges.entries())
+ aSelection.rangedSelect(low, high, iTupe > 0);
+
+ aSelection.currentIndex = this.currentIndex;
+ // This will fire a selectionChanged event
+ aSelection.selectEventsSuppressed = false;
+ },
+};