summaryrefslogtreecommitdiffstats
path: root/comm/mail/base/content/widgets/tree-selection.mjs
blob: 022af7316eb841820c800f663caa5af4bf72d785 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
/* 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;
  }
}