aboutsummaryrefslogtreecommitdiffstats
path: root/node_modules/xterm/src/browser/services/SelectionService.ts
blob: 1ea2395d8ef57693f90041e0eef38d80e6601d58 (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
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
/**
 * Copyright (c) 2017 The xterm.js authors. All rights reserved.
 * @license MIT
 */

import { ISelectionRedrawRequestEvent, ISelectionRequestScrollLinesEvent } from 'browser/selection/Types';
import { IBuffer } from 'common/buffer/Types';
import { IBufferLine, IDisposable } from 'common/Types';
import * as Browser from 'common/Platform';
import { SelectionModel } from 'browser/selection/SelectionModel';
import { CellData } from 'common/buffer/CellData';
import { EventEmitter, IEvent } from 'common/EventEmitter';
import { IMouseService, ISelectionService, IRenderService } from 'browser/services/Services';
import { ILinkifier2 } from 'browser/Types';
import { IBufferService, IOptionsService, ICoreService } from 'common/services/Services';
import { getCoordsRelativeToElement } from 'browser/input/Mouse';
import { moveToCellSequence } from 'browser/input/MoveToCell';
import { Disposable } from 'common/Lifecycle';
import { getRangeLength } from 'common/buffer/BufferRange';

/**
 * The number of pixels the mouse needs to be above or below the viewport in
 * order to scroll at the maximum speed.
 */
const DRAG_SCROLL_MAX_THRESHOLD = 50;

/**
 * The maximum scrolling speed
 */
const DRAG_SCROLL_MAX_SPEED = 15;

/**
 * The number of milliseconds between drag scroll updates.
 */
const DRAG_SCROLL_INTERVAL = 50;

/**
 * The maximum amount of time that can have elapsed for an alt click to move the
 * cursor.
 */
const ALT_CLICK_MOVE_CURSOR_TIME = 500;

const NON_BREAKING_SPACE_CHAR = String.fromCharCode(160);
const ALL_NON_BREAKING_SPACE_REGEX = new RegExp(NON_BREAKING_SPACE_CHAR, 'g');

/**
 * Represents a position of a word on a line.
 */
interface IWordPosition {
  start: number;
  length: number;
}

/**
 * A selection mode, this drives how the selection behaves on mouse move.
 */
export const enum SelectionMode {
  NORMAL,
  WORD,
  LINE,
  COLUMN
}

/**
 * A class that manages the selection of the terminal. With help from
 * SelectionModel, SelectionService handles with all logic associated with
 * dealing with the selection, including handling mouse interaction, wide
 * characters and fetching the actual text within the selection. Rendering is
 * not handled by the SelectionService but the onRedrawRequest event is fired
 * when the selection is ready to be redrawn (on an animation frame).
 */
export class SelectionService extends Disposable implements ISelectionService {
  public serviceBrand: undefined;

  protected _model: SelectionModel;

  /**
   * The amount to scroll every drag scroll update (depends on how far the mouse
   * drag is above or below the terminal).
   */
  private _dragScrollAmount: number = 0;

  /**
   * The current selection mode.
   */
  protected _activeSelectionMode: SelectionMode;

  /**
   * A setInterval timer that is active while the mouse is down whose callback
   * scrolls the viewport when necessary.
   */
  private _dragScrollIntervalTimer: number | undefined;

  /**
   * The animation frame ID used for refreshing the selection.
   */
  private _refreshAnimationFrame: number | undefined;

  /**
   * Whether selection is enabled.
   */
  private _enabled = true;

  private _mouseMoveListener: EventListener;
  private _mouseUpListener: EventListener;
  private _trimListener: IDisposable;
  private _workCell: CellData = new CellData();

  private _mouseDownTimeStamp: number = 0;
  private _oldHasSelection: boolean = false;
  private _oldSelectionStart: [number, number] | undefined = undefined;
  private _oldSelectionEnd: [number, number] | undefined = undefined;

  private _onLinuxMouseSelection = this.register(new EventEmitter<string>());
  public get onLinuxMouseSelection(): IEvent<string> { return this._onLinuxMouseSelection.event; }
  private _onRedrawRequest = this.register(new EventEmitter<ISelectionRedrawRequestEvent>());
  public get onRequestRedraw(): IEvent<ISelectionRedrawRequestEvent> { return this._onRedrawRequest.event; }
  private _onSelectionChange = this.register(new EventEmitter<void>());
  public get onSelectionChange(): IEvent<void> { return this._onSelectionChange.event; }
  private _onRequestScrollLines = this.register(new EventEmitter<ISelectionRequestScrollLinesEvent>());
  public get onRequestScrollLines(): IEvent<ISelectionRequestScrollLinesEvent> { return this._onRequestScrollLines.event; }

  constructor(
    private readonly _element: HTMLElement,
    private readonly _screenElement: HTMLElement,
    private readonly _linkifier: ILinkifier2,
    @IBufferService private readonly _bufferService: IBufferService,
    @ICoreService private readonly _coreService: ICoreService,
    @IMouseService private readonly _mouseService: IMouseService,
    @IOptionsService private readonly _optionsService: IOptionsService,
    @IRenderService private readonly _renderService: IRenderService
  ) {
    super();

    // Init listeners
    this._mouseMoveListener = event => this._onMouseMove(event as MouseEvent);
    this._mouseUpListener = event => this._onMouseUp(event as MouseEvent);
    this._coreService.onUserInput(() => {
      if (this.hasSelection) {
        this.clearSelection();
      }
    });
    this._trimListener = this._bufferService.buffer.lines.onTrim(amount => this._onTrim(amount));
    this.register(this._bufferService.buffers.onBufferActivate(e => this._onBufferActivate(e)));

    this.enable();

    this._model = new SelectionModel(this._bufferService);
    this._activeSelectionMode = SelectionMode.NORMAL;
  }

  public dispose(): void {
    this._removeMouseDownListeners();
  }

  public reset(): void {
    this.clearSelection();
  }

  /**
   * Disables the selection manager. This is useful for when terminal mouse
   * are enabled.
   */
  public disable(): void {
    this.clearSelection();
    this._enabled = false;
  }

  /**
   * Enable the selection manager.
   */
  public enable(): void {
    this._enabled = true;
  }

  public get selectionStart(): [number, number] | undefined { return this._model.finalSelectionStart; }
  public get selectionEnd(): [number, number] | undefined { return this._model.finalSelectionEnd; }

  /**
   * Gets whether there is an active text selection.
   */
  public get hasSelection(): boolean {
    const start = this._model.finalSelectionStart;
    const end = this._model.finalSelectionEnd;
    if (!start || !end) {
      return false;
    }
    return start[0] !== end[0] || start[1] !== end[1];
  }

  /**
   * Gets the text currently selected.
   */
  public get selectionText(): string {
    const start = this._model.finalSelectionStart;
    const end = this._model.finalSelectionEnd;
    if (!start || !end) {
      return '';
    }

    const buffer = this._bufferService.buffer;
    const result: string[] = [];

    if (this._activeSelectionMode === SelectionMode.COLUMN) {
      // Ignore zero width selections
      if (start[0] === end[0]) {
        return '';
      }

      for (let i = start[1]; i <= end[1]; i++) {
        const lineText = buffer.translateBufferLineToString(i, true, start[0], end[0]);
        result.push(lineText);
      }
    } else {
      // Get first row
      const startRowEndCol = start[1] === end[1] ? end[0] : undefined;
      result.push(buffer.translateBufferLineToString(start[1], true, start[0], startRowEndCol));

      // Get middle rows
      for (let i = start[1] + 1; i <= end[1] - 1; i++) {
        const bufferLine = buffer.lines.get(i);
        const lineText = buffer.translateBufferLineToString(i, true);
        if (bufferLine?.isWrapped) {
          result[result.length - 1] += lineText;
        } else {
          result.push(lineText);
        }
      }

      // Get final row
      if (start[1] !== end[1]) {
        const bufferLine = buffer.lines.get(end[1]);
        const lineText = buffer.translateBufferLineToString(end[1], true, 0, end[0]);
        if (bufferLine && bufferLine!.isWrapped) {
          result[result.length - 1] += lineText;
        } else {
          result.push(lineText);
        }
      }
    }

    // Format string by replacing non-breaking space chars with regular spaces
    // and joining the array into a multi-line string.
    const formattedResult = result.map(line => {
      return line.replace(ALL_NON_BREAKING_SPACE_REGEX, ' ');
    }).join(Browser.isWindows ? '\r\n' : '\n');

    return formattedResult;
  }

  /**
   * Clears the current terminal selection.
   */
  public clearSelection(): void {
    this._model.clearSelection();
    this._removeMouseDownListeners();
    this.refresh();
    this._onSelectionChange.fire();
  }

  /**
   * Queues a refresh, redrawing the selection on the next opportunity.
   * @param isLinuxMouseSelection Whether the selection should be registered as a new
   * selection on Linux.
   */
  public refresh(isLinuxMouseSelection?: boolean): void {
    // Queue the refresh for the renderer
    if (!this._refreshAnimationFrame) {
      this._refreshAnimationFrame = window.requestAnimationFrame(() => this._refresh());
    }

    // If the platform is Linux and the refresh call comes from a mouse event,
    // we need to update the selection for middle click to paste selection.
    if (Browser.isLinux && isLinuxMouseSelection) {
      const selectionText = this.selectionText;
      if (selectionText.length) {
        this._onLinuxMouseSelection.fire(this.selectionText);
      }
    }
  }

  /**
   * Fires the refresh event, causing consumers to pick it up and redraw the
   * selection state.
   */
  private _refresh(): void {
    this._refreshAnimationFrame = undefined;
    this._onRedrawRequest.fire({
      start: this._model.finalSelectionStart,
      end: this._model.finalSelectionEnd,
      columnSelectMode: this._activeSelectionMode === SelectionMode.COLUMN
    });
  }

  /**
   * Checks if the current click was inside the current selection
   * @param event The mouse event
   */
  private _isClickInSelection(event: MouseEvent): boolean {
    const coords = this._getMouseBufferCoords(event);
    const start = this._model.finalSelectionStart;
    const end = this._model.finalSelectionEnd;

    if (!start || !end || !coords) {
      return false;
    }

    return this._areCoordsInSelection(coords, start, end);
  }

  protected _areCoordsInSelection(coords: [number, number], start: [number, number], end: [number, number]): boolean {
    return (coords[1] > start[1] && coords[1] < end[1]) ||
        (start[1] === end[1] && coords[1] === start[1] && coords[0] >= start[0] && coords[0] < end[0]) ||
        (start[1] < end[1] && coords[1] === end[1] && coords[0] < end[0]) ||
        (start[1] < end[1] && coords[1] === start[1] && coords[0] >= start[0]);
  }

  /**
   * Selects word at the current mouse event coordinates.
   * @param event The mouse event.
   */
  private _selectWordAtCursor(event: MouseEvent, allowWhitespaceOnlySelection: boolean): boolean {
    // Check if there is a link under the cursor first and select that if so
    const range = this._linkifier.currentLink?.link?.range;
    if (range) {
      this._model.selectionStart = [range.start.x - 1, range.start.y - 1];
      this._model.selectionStartLength = getRangeLength(range, this._bufferService.cols);
      this._model.selectionEnd = undefined;
      return true;
    }

    const coords = this._getMouseBufferCoords(event);
    if (coords) {
      this._selectWordAt(coords, allowWhitespaceOnlySelection);
      this._model.selectionEnd = undefined;
      return true;
    }
    return false;
  }

  /**
   * Selects all text within the terminal.
   */
  public selectAll(): void {
    this._model.isSelectAllActive = true;
    this.refresh();
    this._onSelectionChange.fire();
  }

  public selectLines(start: number, end: number): void {
    this._model.clearSelection();
    start = Math.max(start, 0);
    end = Math.min(end, this._bufferService.buffer.lines.length - 1);
    this._model.selectionStart = [0, start];
    this._model.selectionEnd = [this._bufferService.cols, end];
    this.refresh();
    this._onSelectionChange.fire();
  }

  /**
   * Handle the buffer being trimmed, adjust the selection position.
   * @param amount The amount the buffer is being trimmed.
   */
  private _onTrim(amount: number): void {
    const needsRefresh = this._model.onTrim(amount);
    if (needsRefresh) {
      this.refresh();
    }
  }

  /**
   * Gets the 0-based [x, y] buffer coordinates of the current mouse event.
   * @param event The mouse event.
   */
  private _getMouseBufferCoords(event: MouseEvent): [number, number] | undefined {
    const coords = this._mouseService.getCoords(event, this._screenElement, this._bufferService.cols, this._bufferService.rows, true);
    if (!coords) {
      return undefined;
    }

    // Convert to 0-based
    coords[0]--;
    coords[1]--;

    // Convert viewport coords to buffer coords
    coords[1] += this._bufferService.buffer.ydisp;
    return coords;
  }

  /**
   * Gets the amount the viewport should be scrolled based on how far out of the
   * terminal the mouse is.
   * @param event The mouse event.
   */
  private _getMouseEventScrollAmount(event: MouseEvent): number {
    let offset = getCoordsRelativeToElement(event, this._screenElement)[1];
    const terminalHeight = this._renderService.dimensions.canvasHeight;
    if (offset >= 0 && offset <= terminalHeight) {
      return 0;
    }
    if (offset > terminalHeight) {
      offset -= terminalHeight;
    }

    offset = Math.min(Math.max(offset, -DRAG_SCROLL_MAX_THRESHOLD), DRAG_SCROLL_MAX_THRESHOLD);
    offset /= DRAG_SCROLL_MAX_THRESHOLD;
    return (offset / Math.abs(offset)) + Math.round(offset * (DRAG_SCROLL_MAX_SPEED - 1));
  }

  /**
   * Returns whether the selection manager should force selection, regardless of
   * whether the terminal is in mouse events mode.
   * @param event The mouse event.
   */
  public shouldForceSelection(event: MouseEvent): boolean {
    if (Browser.isMac) {
      return event.altKey && this._optionsService.rawOptions.macOptionClickForcesSelection;
    }

    return event.shiftKey;
  }

  /**
   * Handles te mousedown event, setting up for a new selection.
   * @param event The mousedown event.
   */
  public onMouseDown(event: MouseEvent): void {
    this._mouseDownTimeStamp = event.timeStamp;
    // If we have selection, we want the context menu on right click even if the
    // terminal is in mouse mode.
    if (event.button === 2 && this.hasSelection) {
      return;
    }

    // Only action the primary button
    if (event.button !== 0) {
      return;
    }

    // Allow selection when using a specific modifier key, even when disabled
    if (!this._enabled) {
      if (!this.shouldForceSelection(event)) {
        return;
      }

      // Don't send the mouse down event to the current process, we want to select
      event.stopPropagation();
    }

    // Tell the browser not to start a regular selection
    event.preventDefault();

    // Reset drag scroll state
    this._dragScrollAmount = 0;

    if (this._enabled && event.shiftKey) {
      this._onIncrementalClick(event);
    } else {
      if (event.detail === 1) {
        this._onSingleClick(event);
      } else if (event.detail === 2) {
        this._onDoubleClick(event);
      } else if (event.detail === 3) {
        this._onTripleClick(event);
      }
    }

    this._addMouseDownListeners();
    this.refresh(true);
  }

  /**
   * Adds listeners when mousedown is triggered.
   */
  private _addMouseDownListeners(): void {
    // Listen on the document so that dragging outside of viewport works
    if (this._screenElement.ownerDocument) {
      this._screenElement.ownerDocument.addEventListener('mousemove', this._mouseMoveListener);
      this._screenElement.ownerDocument.addEventListener('mouseup', this._mouseUpListener);
    }
    this._dragScrollIntervalTimer = window.setInterval(() => this._dragScroll(), DRAG_SCROLL_INTERVAL);
  }

  /**
   * Removes the listeners that are registered when mousedown is triggered.
   */
  private _removeMouseDownListeners(): void {
    if (this._screenElement.ownerDocument) {
      this._screenElement.ownerDocument.removeEventListener('mousemove', this._mouseMoveListener);
      this._screenElement.ownerDocument.removeEventListener('mouseup', this._mouseUpListener);
    }
    clearInterval(this._dragScrollIntervalTimer);
    this._dragScrollIntervalTimer = undefined;
  }

  /**
   * Performs an incremental click, setting the selection end position to the mouse
   * position.
   * @param event The mouse event.
   */
  private _onIncrementalClick(event: MouseEvent): void {
    if (this._model.selectionStart) {
      this._model.selectionEnd = this._getMouseBufferCoords(event);
    }
  }

  /**
   * Performs a single click, resetting relevant state and setting the selection
   * start position.
   * @param event The mouse event.
   */
  private _onSingleClick(event: MouseEvent): void {
    this._model.selectionStartLength = 0;
    this._model.isSelectAllActive = false;
    this._activeSelectionMode = this.shouldColumnSelect(event) ? SelectionMode.COLUMN : SelectionMode.NORMAL;

    // Initialize the new selection
    this._model.selectionStart = this._getMouseBufferCoords(event);
    if (!this._model.selectionStart) {
      return;
    }
    this._model.selectionEnd = undefined;

    // Ensure the line exists
    const line = this._bufferService.buffer.lines.get(this._model.selectionStart[1]);
    if (!line) {
      return;
    }

    // Return early if the click event is not in the buffer (eg. in scroll bar)
    if (line.length === this._model.selectionStart[0]) {
      return;
    }

    // If the mouse is over the second half of a wide character, adjust the
    // selection to cover the whole character
    if (line.hasWidth(this._model.selectionStart[0]) === 0) {
      this._model.selectionStart[0]++;
    }
  }

  /**
   * Performs a double click, selecting the current word.
   * @param event The mouse event.
   */
  private _onDoubleClick(event: MouseEvent): void {
    if (this._selectWordAtCursor(event, true)) {
      this._activeSelectionMode = SelectionMode.WORD;
    }
  }

  /**
   * Performs a triple click, selecting the current line and activating line
   * select mode.
   * @param event The mouse event.
   */
  private _onTripleClick(event: MouseEvent): void {
    const coords = this._getMouseBufferCoords(event);
    if (coords) {
      this._activeSelectionMode = SelectionMode.LINE;
      this._selectLineAt(coords[1]);
    }
  }

  /**
   * Returns whether the selection manager should operate in column select mode
   * @param event the mouse or keyboard event
   */
  public shouldColumnSelect(event: KeyboardEvent | MouseEvent): boolean {
    return event.altKey && !(Browser.isMac && this._optionsService.rawOptions.macOptionClickForcesSelection);
  }

  /**
   * Handles the mousemove event when the mouse button is down, recording the
   * end of the selection and refreshing the selection.
   * @param event The mousemove event.
   */
  private _onMouseMove(event: MouseEvent): void {
    // If the mousemove listener is active it means that a selection is
    // currently being made, we should stop propagation to prevent mouse events
    // to be sent to the pty.
    event.stopImmediatePropagation();

    // Do nothing if there is no selection start, this can happen if the first
    // click in the terminal is an incremental click
    if (!this._model.selectionStart) {
      return;
    }

    // Record the previous position so we know whether to redraw the selection
    // at the end.
    const previousSelectionEnd = this._model.selectionEnd ? [this._model.selectionEnd[0], this._model.selectionEnd[1]] : null;

    // Set the initial selection end based on the mouse coordinates
    this._model.selectionEnd = this._getMouseBufferCoords(event);
    if (!this._model.selectionEnd) {
      this.refresh(true);
      return;
    }

    // Select the entire line if line select mode is active.
    if (this._activeSelectionMode === SelectionMode.LINE) {
      if (this._model.selectionEnd[1] < this._model.selectionStart[1]) {
        this._model.selectionEnd[0] = 0;
      } else {
        this._model.selectionEnd[0] = this._bufferService.cols;
      }
    } else if (this._activeSelectionMode === SelectionMode.WORD) {
      this._selectToWordAt(this._model.selectionEnd);
    }

    // Determine the amount of scrolling that will happen.
    this._dragScrollAmount = this._getMouseEventScrollAmount(event);

    // If the cursor was above or below the viewport, make sure it's at the
    // start or end of the viewport respectively. This should only happen when
    // NOT in column select mode.
    if (this._activeSelectionMode !== SelectionMode.COLUMN) {
      if (this._dragScrollAmount > 0) {
        this._model.selectionEnd[0] = this._bufferService.cols;
      } else if (this._dragScrollAmount < 0) {
        this._model.selectionEnd[0] = 0;
      }
    }

    // If the character is a wide character include the cell to the right in the
    // selection. Note that selections at the very end of the line will never
    // have a character.
    const buffer = this._bufferService.buffer;
    if (this._model.selectionEnd[1] < buffer.lines.length) {
      const line = buffer.lines.get(this._model.selectionEnd[1]);
      if (line && line.hasWidth(this._model.selectionEnd[0]) === 0) {
        this._model.selectionEnd[0]++;
      }
    }

    // Only draw here if the selection changes.
    if (!previousSelectionEnd ||
      previousSelectionEnd[0] !== this._model.selectionEnd[0] ||
      previousSelectionEnd[1] !== this._model.selectionEnd[1]) {
      this.refresh(true);
    }
  }

  /**
   * The callback that occurs every DRAG_SCROLL_INTERVAL ms that does the
   * scrolling of the viewport.
   */
  private _dragScroll(): void {
    if (!this._model.selectionEnd || !this._model.selectionStart) {
      return;
    }
    if (this._dragScrollAmount) {
      this._onRequestScrollLines.fire({ amount: this._dragScrollAmount, suppressScrollEvent: false });
      // Re-evaluate selection
      // If the cursor was above or below the viewport, make sure it's at the
      // start or end of the viewport respectively. This should only happen when
      // NOT in column select mode.
      const buffer = this._bufferService.buffer;
      if (this._dragScrollAmount > 0) {
        if (this._activeSelectionMode !== SelectionMode.COLUMN) {
          this._model.selectionEnd[0] = this._bufferService.cols;
        }
        this._model.selectionEnd[1] = Math.min(buffer.ydisp + this._bufferService.rows, buffer.lines.length - 1);
      } else {
        if (this._activeSelectionMode !== SelectionMode.COLUMN) {
          this._model.selectionEnd[0] = 0;
        }
        this._model.selectionEnd[1] = buffer.ydisp;
      }
      this.refresh();
    }
  }

  /**
   * Handles the mouseup event, removing the mousedown listeners.
   * @param event The mouseup event.
   */
  private _onMouseUp(event: MouseEvent): void {
    const timeElapsed = event.timeStamp - this._mouseDownTimeStamp;

    this._removeMouseDownListeners();

    if (this.selectionText.length <= 1 && timeElapsed < ALT_CLICK_MOVE_CURSOR_TIME && event.altKey && this._optionsService.getOption('altClickMovesCursor')) {
      if (this._bufferService.buffer.ybase === this._bufferService.buffer.ydisp) {
        const coordinates = this._mouseService.getCoords(
          event,
          this._element,
          this._bufferService.cols,
          this._bufferService.rows,
          false
        );
        if (coordinates && coordinates[0] !== undefined && coordinates[1] !== undefined) {
          const sequence = moveToCellSequence(coordinates[0] - 1, coordinates[1] - 1, this._bufferService, this._coreService.decPrivateModes.applicationCursorKeys);
          this._coreService.triggerDataEvent(sequence, true);
        }
      }
    } else {
      this._fireEventIfSelectionChanged();
    }
  }

  private _fireEventIfSelectionChanged(): void {
    const start = this._model.finalSelectionStart;
    const end = this._model.finalSelectionEnd;
    const hasSelection = !!start && !!end && (start[0] !== end[0] || start[1] !== end[1]);

    if (!hasSelection) {
      if (this._oldHasSelection) {
        this._fireOnSelectionChange(start, end, hasSelection);
      }
      return;
    }

    // Sanity check, these should not be undefined as there is a selection
    if (!start || !end) {
      return;
    }

    if (!this._oldSelectionStart || !this._oldSelectionEnd || (
      start[0] !== this._oldSelectionStart[0] || start[1] !== this._oldSelectionStart[1] ||
      end[0] !== this._oldSelectionEnd[0] || end[1] !== this._oldSelectionEnd[1])) {

      this._fireOnSelectionChange(start, end, hasSelection);
    }
  }

  private _fireOnSelectionChange(start: [number, number] | undefined, end: [number, number] | undefined, hasSelection: boolean): void {
    this._oldSelectionStart = start;
    this._oldSelectionEnd = end;
    this._oldHasSelection = hasSelection;
    this._onSelectionChange.fire();
  }

  private _onBufferActivate(e: {activeBuffer: IBuffer, inactiveBuffer: IBuffer}): void {
    this.clearSelection();
    // Only adjust the selection on trim, shiftElements is rarely used (only in
    // reverseIndex) and delete in a splice is only ever used when the same
    // number of elements was just added. Given this is could actually be
    // beneficial to leave the selection as is for these cases.
    this._trimListener.dispose();
    this._trimListener = e.activeBuffer.lines.onTrim(amount => this._onTrim(amount));
  }

  /**
   * Converts a viewport column to the character index on the buffer line, the
   * latter takes into account wide characters.
   * @param coords The coordinates to find the 2 index for.
   */
  private _convertViewportColToCharacterIndex(bufferLine: IBufferLine, coords: [number, number]): number {
    let charIndex = coords[0];
    for (let i = 0; coords[0] >= i; i++) {
      const length = bufferLine.loadCell(i, this._workCell).getChars().length;
      if (this._workCell.getWidth() === 0) {
        // Wide characters aren't included in the line string so decrement the
        // index so the index is back on the wide character.
        charIndex--;
      } else if (length > 1 && coords[0] !== i) {
        // Emojis take up multiple characters, so adjust accordingly. For these
        // we don't want ot include the character at the column as we're
        // returning the start index in the string, not the end index.
        charIndex += length - 1;
      }
    }
    return charIndex;
  }

  public setSelection(col: number, row: number, length: number): void {
    this._model.clearSelection();
    this._removeMouseDownListeners();
    this._model.selectionStart = [col, row];
    this._model.selectionStartLength = length;
    this.refresh();
  }

  public rightClickSelect(ev: MouseEvent): void {
    if (!this._isClickInSelection(ev)) {
      if (this._selectWordAtCursor(ev, false)) {
        this.refresh(true);
      }
      this._fireEventIfSelectionChanged();
    }
  }

  /**
   * Gets positional information for the word at the coordinated specified.
   * @param coords The coordinates to get the word at.
   */
  private _getWordAt(coords: [number, number], allowWhitespaceOnlySelection: boolean, followWrappedLinesAbove: boolean = true, followWrappedLinesBelow: boolean = true): IWordPosition | undefined {
    // Ensure coords are within viewport (eg. not within scroll bar)
    if (coords[0] >= this._bufferService.cols) {
      return undefined;
    }

    const buffer = this._bufferService.buffer;
    const bufferLine = buffer.lines.get(coords[1]);
    if (!bufferLine) {
      return undefined;
    }

    const line = buffer.translateBufferLineToString(coords[1], false);

    // Get actual index, taking into consideration wide characters
    let startIndex = this._convertViewportColToCharacterIndex(bufferLine, coords);
    let endIndex = startIndex;

    // Record offset to be used later
    const charOffset = coords[0] - startIndex;
    let leftWideCharCount = 0;
    let rightWideCharCount = 0;
    let leftLongCharOffset = 0;
    let rightLongCharOffset = 0;

    if (line.charAt(startIndex) === ' ') {
      // Expand until non-whitespace is hit
      while (startIndex > 0 && line.charAt(startIndex - 1) === ' ') {
        startIndex--;
      }
      while (endIndex < line.length && line.charAt(endIndex + 1) === ' ') {
        endIndex++;
      }
    } else {
      // Expand until whitespace is hit. This algorithm works by scanning left
      // and right from the starting position, keeping both the index format
      // (line) and the column format (bufferLine) in sync. When a wide
      // character is hit, it is recorded and the column index is adjusted.
      let startCol = coords[0];
      let endCol = coords[0];

      // Consider the initial position, skip it and increment the wide char
      // variable
      if (bufferLine.getWidth(startCol) === 0) {
        leftWideCharCount++;
        startCol--;
      }
      if (bufferLine.getWidth(endCol) === 2) {
        rightWideCharCount++;
        endCol++;
      }

      // Adjust the end index for characters whose length are > 1 (emojis)
      const length = bufferLine.getString(endCol).length;
      if (length > 1) {
        rightLongCharOffset += length - 1;
        endIndex += length - 1;
      }

      // Expand the string in both directions until a space is hit
      while (startCol > 0 && startIndex > 0 && !this._isCharWordSeparator(bufferLine.loadCell(startCol - 1, this._workCell))) {
        bufferLine.loadCell(startCol - 1, this._workCell);
        const length = this._workCell.getChars().length;
        if (this._workCell.getWidth() === 0) {
          // If the next character is a wide char, record it and skip the column
          leftWideCharCount++;
          startCol--;
        } else if (length > 1) {
          // If the next character's string is longer than 1 char (eg. emoji),
          // adjust the index
          leftLongCharOffset += length - 1;
          startIndex -= length - 1;
        }
        startIndex--;
        startCol--;
      }
      while (endCol < bufferLine.length && endIndex + 1 < line.length && !this._isCharWordSeparator(bufferLine.loadCell(endCol + 1, this._workCell))) {
        bufferLine.loadCell(endCol + 1, this._workCell);
        const length = this._workCell.getChars().length;
        if (this._workCell.getWidth() === 2) {
          // If the next character is a wide char, record it and skip the column
          rightWideCharCount++;
          endCol++;
        } else if (length > 1) {
          // If the next character's string is longer than 1 char (eg. emoji),
          // adjust the index
          rightLongCharOffset += length - 1;
          endIndex += length - 1;
        }
        endIndex++;
        endCol++;
      }
    }

    // Incremenet the end index so it is at the start of the next character
    endIndex++;

    // Calculate the start _column_, converting the the string indexes back to
    // column coordinates.
    let start =
        startIndex // The index of the selection's start char in the line string
        + charOffset // The difference between the initial char's column and index
        - leftWideCharCount // The number of wide chars left of the initial char
        + leftLongCharOffset; // The number of additional chars left of the initial char added by columns with strings longer than 1 (emojis)

    // Calculate the length in _columns_, converting the the string indexes back
    // to column coordinates.
    let length = Math.min(this._bufferService.cols, // Disallow lengths larger than the terminal cols
      endIndex // The index of the selection's end char in the line string
      - startIndex // The index of the selection's start char in the line string
      + leftWideCharCount // The number of wide chars left of the initial char
      + rightWideCharCount // The number of wide chars right of the initial char (inclusive)
      - leftLongCharOffset // The number of additional chars left of the initial char added by columns with strings longer than 1 (emojis)
      - rightLongCharOffset); // The number of additional chars right of the initial char (inclusive) added by columns with strings longer than 1 (emojis)

    if (!allowWhitespaceOnlySelection && line.slice(startIndex, endIndex).trim() === '') {
      return undefined;
    }

    // Recurse upwards if the line is wrapped and the word wraps to the above line
    if (followWrappedLinesAbove) {
      if (start === 0 && bufferLine.getCodePoint(0) !== 32 /* ' ' */) {
        const previousBufferLine = buffer.lines.get(coords[1] - 1);
        if (previousBufferLine && bufferLine.isWrapped && previousBufferLine.getCodePoint(this._bufferService.cols - 1) !== 32 /* ' ' */) {
          const previousLineWordPosition = this._getWordAt([this._bufferService.cols - 1, coords[1] - 1], false, true, false);
          if (previousLineWordPosition) {
            const offset = this._bufferService.cols - previousLineWordPosition.start;
            start -= offset;
            length += offset;
          }
        }
      }
    }

    // Recurse downwards if the line is wrapped and the word wraps to the next line
    if (followWrappedLinesBelow) {
      if (start + length === this._bufferService.cols && bufferLine.getCodePoint(this._bufferService.cols - 1) !== 32 /* ' ' */) {
        const nextBufferLine = buffer.lines.get(coords[1] + 1);
        if (nextBufferLine?.isWrapped && nextBufferLine.getCodePoint(0) !== 32 /* ' ' */) {
          const nextLineWordPosition = this._getWordAt([0, coords[1] + 1], false, false, true);
          if (nextLineWordPosition) {
            length += nextLineWordPosition.length;
          }
        }
      }
    }

    return { start, length };
  }

  /**
   * Selects the word at the coordinates specified.
   * @param coords The coordinates to get the word at.
   * @param allowWhitespaceOnlySelection If whitespace should be selected
   */
  protected _selectWordAt(coords: [number, number], allowWhitespaceOnlySelection: boolean): void {
    const wordPosition = this._getWordAt(coords, allowWhitespaceOnlySelection);
    if (wordPosition) {
      // Adjust negative start value
      while (wordPosition.start < 0) {
        wordPosition.start += this._bufferService.cols;
        coords[1]--;
      }
      this._model.selectionStart = [wordPosition.start, coords[1]];
      this._model.selectionStartLength = wordPosition.length;
    }
  }

  /**
   * Sets the selection end to the word at the coordinated specified.
   * @param coords The coordinates to get the word at.
   */
  private _selectToWordAt(coords: [number, number]): void {
    const wordPosition = this._getWordAt(coords, true);
    if (wordPosition) {
      let endRow = coords[1];

      // Adjust negative start value
      while (wordPosition.start < 0) {
        wordPosition.start += this._bufferService.cols;
        endRow--;
      }

      // Adjust wrapped length value, this only needs to happen when values are reversed as in that
      // case we're interested in the start of the word, not the end
      if (!this._model.areSelectionValuesReversed()) {
        while (wordPosition.start + wordPosition.length > this._bufferService.cols) {
          wordPosition.length -= this._bufferService.cols;
          endRow++;
        }
      }

      this._model.selectionEnd = [this._model.areSelectionValuesReversed() ? wordPosition.start : wordPosition.start + wordPosition.length, endRow];
    }
  }

  /**
   * Gets whether the character is considered a word separator by the select
   * word logic.
   * @param char The character to check.
   */
  private _isCharWordSeparator(cell: CellData): boolean {
    // Zero width characters are never separators as they are always to the
    // right of wide characters
    if (cell.getWidth() === 0) {
      return false;
    }
    return this._optionsService.rawOptions.wordSeparator.indexOf(cell.getChars()) >= 0;
  }

  /**
   * Selects the line specified.
   * @param line The line index.
   */
  protected _selectLineAt(line: number): void {
    const wrappedRange = this._bufferService.buffer.getWrappedRangeForLine(line);
    this._model.selectionStart = [0, wrappedRange.first];
    this._model.selectionEnd = [this._bufferService.cols, wrappedRange.last];
    this._model.selectionStartLength = 0;
  }
}