// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2019, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
import { IDisposable } from '@lumino/disposable';

import { ClipboardExt, ElementExt, Platform } from '@lumino/domutils';

import {
  ConflatableMessage,
  IMessageHandler,
  Message,
  MessageLoop
} from '@lumino/messaging';

import { GridLayout, ScrollBar, Widget } from '@lumino/widgets';

import { AsyncCellRenderer } from './asynccellrenderer';

import { CellRenderer } from './cellrenderer';

import { DataModel, MutableDataModel } from './datamodel';

import { CellGroup } from './cellgroup';

import { GraphicsContext } from './graphicscontext';

import { RendererMap } from './renderermap';

import { SectionList } from './sectionlist';

import { SelectionModel } from './selectionmodel';

import {
  CellEditorController,
  ICellEditorController
} from './celleditorcontroller';

import { TextRenderer } from './textrenderer';

/**
 * A widget which implements a high-performance tabular data grid.
 *
 * #### Notes
 * A data grid is implemented as a composition of child widgets. These
 * child widgets are considered an implementation detail. Manipulating
 * the child widgets of a data grid directly is undefined behavior.
 *
 * This class is not designed to be subclassed.
 *
 * See also the related [example](../../examples/datagrid/index.html) and
 * its [source](https://github.com/jupyterlab/lumino/tree/main/examples/example-datagrid).
 */
export class DataGrid extends Widget {
  /**
   * Construct a new data grid.
   *
   * @param options - The options for initializing the data grid.
   */
  constructor(options: DataGrid.IOptions = {}) {
    super();
    this.addClass('lm-DataGrid');

    // Parse the simple options.
    this._style = options.style || DataGrid.defaultStyle;
    this._stretchLastRow = options.stretchLastRow || false;
    this._stretchLastColumn = options.stretchLastColumn || false;
    this._headerVisibility = options.headerVisibility || 'all';
    this._cellRenderers = options.cellRenderers || new RendererMap();
    this._copyConfig = options.copyConfig || DataGrid.defaultCopyConfig;

    // Connect to the renderer map changed signal.
    this._cellRenderers.changed.connect(this._onRenderersChanged, this);

    // Parse the default sizes.
    let defaultSizes = options.defaultSizes || DataGrid.defaultSizes;
    let minimumSizes = options.minimumSizes || DataGrid.minimumSizes;

    // Set up the sections lists.
    this._rowSections = new SectionList({
      defaultSize: defaultSizes.rowHeight,
      minimumSize: minimumSizes.rowHeight
    });
    this._columnSections = new SectionList({
      defaultSize: defaultSizes.columnWidth,
      minimumSize: minimumSizes.columnWidth
    });
    this._rowHeaderSections = new SectionList({
      defaultSize: defaultSizes.rowHeaderWidth,
      minimumSize: minimumSizes.rowHeaderWidth
    });
    this._columnHeaderSections = new SectionList({
      defaultSize: defaultSizes.columnHeaderHeight,
      minimumSize: minimumSizes.columnHeaderHeight
    });

    // Create the canvas, buffer, and overlay objects.
    this._canvas = Private.createCanvas();
    this._buffer = Private.createCanvas();
    this._overlay = Private.createCanvas();

    // Get the graphics contexts for the canvases.
    this._canvasGC = this._canvas.getContext('2d')!;
    this._bufferGC = this._buffer.getContext('2d')!;
    this._overlayGC = this._overlay.getContext('2d')!;

    // Set up the on-screen canvas.
    this._canvas.style.position = 'absolute';
    this._canvas.style.top = '0px';
    this._canvas.style.left = '0px';
    this._canvas.style.width = '0px';
    this._canvas.style.height = '0px';

    // Set up the on-screen overlay.
    this._overlay.style.position = 'absolute';
    this._overlay.style.top = '0px';
    this._overlay.style.left = '0px';
    this._overlay.style.width = '0px';
    this._overlay.style.height = '0px';

    // Create the internal widgets for the data grid.
    this._viewport = new Widget();
    this._viewport.node.tabIndex = -1;
    this._viewport.node.style.outline = 'none';
    this._vScrollBar = new ScrollBar({ orientation: 'vertical' });
    this._hScrollBar = new ScrollBar({ orientation: 'horizontal' });
    this._scrollCorner = new Widget();

    this._editorController = new CellEditorController();

    // Add the extra class names to the child widgets.
    this._viewport.addClass('lm-DataGrid-viewport');
    this._vScrollBar.addClass('lm-DataGrid-scrollBar');
    this._hScrollBar.addClass('lm-DataGrid-scrollBar');
    this._scrollCorner.addClass('lm-DataGrid-scrollCorner');

    // Add the on-screen canvas to the viewport node.
    this._viewport.node.appendChild(this._canvas);

    // Add the on-screen overlay to the viewport node.
    this._viewport.node.appendChild(this._overlay);

    // Install the message hooks.
    MessageLoop.installMessageHook(this._viewport, this);
    MessageLoop.installMessageHook(this._hScrollBar, this);
    MessageLoop.installMessageHook(this._vScrollBar, this);

    // Hide the scroll bars and corner from the outset.
    this._vScrollBar.hide();
    this._hScrollBar.hide();
    this._scrollCorner.hide();

    // Connect to the scroll bar signals.
    this._vScrollBar.thumbMoved.connect(this._onThumbMoved, this);
    this._hScrollBar.thumbMoved.connect(this._onThumbMoved, this);
    this._vScrollBar.pageRequested.connect(this._onPageRequested, this);
    this._hScrollBar.pageRequested.connect(this._onPageRequested, this);
    this._vScrollBar.stepRequested.connect(this._onStepRequested, this);
    this._hScrollBar.stepRequested.connect(this._onStepRequested, this);

    // Set the layout cell config for the child widgets.
    GridLayout.setCellConfig(this._viewport, { row: 0, column: 0 });
    GridLayout.setCellConfig(this._vScrollBar, { row: 0, column: 1 });
    GridLayout.setCellConfig(this._hScrollBar, { row: 1, column: 0 });
    GridLayout.setCellConfig(this._scrollCorner, { row: 1, column: 1 });

    // Create the layout for the data grid.
    let layout = new GridLayout({
      rowCount: 2,
      columnCount: 2,
      rowSpacing: 0,
      columnSpacing: 0,
      fitPolicy: 'set-no-constraint'
    });

    // Set the stretch factors for the grid.
    layout.setRowStretch(0, 1);
    layout.setRowStretch(1, 0);
    layout.setColumnStretch(0, 1);
    layout.setColumnStretch(1, 0);

    // Add the child widgets to the layout.
    layout.addWidget(this._viewport);
    layout.addWidget(this._vScrollBar);
    layout.addWidget(this._hScrollBar);
    layout.addWidget(this._scrollCorner);

    // Install the layout on the data grid.
    this.layout = layout;
  }

  /**
   * Dispose of the resources held by the widgets.
   */
  dispose(): void {
    // Release the mouse.
    this._releaseMouse();

    // Dispose of the handlers.
    if (this._keyHandler) {
      this._keyHandler.dispose();
    }
    if (this._mouseHandler) {
      this._mouseHandler.dispose();
    }
    this._keyHandler = null;
    this._mouseHandler = null;

    // Clear the models.
    this._dataModel = null;
    this._selectionModel = null;

    // Clear the section lists.
    this._rowSections.clear();
    this._columnSections.clear();
    this._rowHeaderSections.clear();
    this._columnHeaderSections.clear();

    // Dispose of the base class.
    super.dispose();
  }

  /**
   * Get the data model for the data grid.
   */
  get dataModel(): DataModel | null {
    return this._dataModel;
  }

  /**
   * Set the data model for the data grid.
   *
   * #### Notes
   * This will automatically remove the current selection model.
   */
  set dataModel(value: DataModel | null) {
    // Do nothing if the model does not change.
    if (this._dataModel === value) {
      return;
    }

    // Release the mouse.
    this._releaseMouse();

    // Clear the selection model.
    this.selectionModel = null;

    // Disconnect the change handler from the old model.
    if (this._dataModel) {
      this._dataModel.changed.disconnect(this._onDataModelChanged, this);
    }

    // Connect the change handler for the new model.
    if (value) {
      value.changed.connect(this._onDataModelChanged, this);
    }

    // Update the internal model reference.
    this._dataModel = value;

    // Clear the section lists.
    this._rowSections.clear();
    this._columnSections.clear();
    this._rowHeaderSections.clear();
    this._columnHeaderSections.clear();

    // Populate the section lists.
    if (value) {
      this._rowSections.insert(0, value.rowCount('body'));
      this._columnSections.insert(0, value.columnCount('body'));
      this._rowHeaderSections.insert(0, value.columnCount('row-header'));
      this._columnHeaderSections.insert(0, value.rowCount('column-header'));
    }

    // Reset the scroll position.
    this._scrollX = 0;
    this._scrollY = 0;

    // Sync the viewport.
    this._syncViewport();
  }

  /**
   * Get the selection model for the data grid.
   */
  get selectionModel(): SelectionModel | null {
    return this._selectionModel;
  }

  /**
   * Set the selection model for the data grid.
   */
  set selectionModel(value: SelectionModel | null) {
    // Do nothing if the selection model does not change.
    if (this._selectionModel === value) {
      return;
    }

    // Release the mouse.
    this._releaseMouse();

    // Ensure the data models are a match.
    if (value && value.dataModel !== this._dataModel) {
      throw new Error('SelectionModel.dataModel !== DataGrid.dataModel');
    }

    // Disconnect the change handler from the old model.
    if (this._selectionModel) {
      this._selectionModel.changed.disconnect(this._onSelectionsChanged, this);
    }

    // Connect the change handler for the new model.
    if (value) {
      value.changed.connect(this._onSelectionsChanged, this);
    }

    // Update the internal selection model reference.
    this._selectionModel = value;

    // Schedule a repaint of the overlay.
    this.repaintOverlay();
  }

  /**
   * Get the key handler for the data grid.
   */
  get keyHandler(): DataGrid.IKeyHandler | null {
    return this._keyHandler;
  }

  /**
   * Set the key handler for the data grid.
   */
  set keyHandler(value: DataGrid.IKeyHandler | null) {
    this._keyHandler = value;
  }

  /**
   * Get the mouse handler for the data grid.
   */
  get mouseHandler(): DataGrid.IMouseHandler | null {
    return this._mouseHandler;
  }

  /**
   * Set the mouse handler for the data grid.
   */
  set mouseHandler(value: DataGrid.IMouseHandler | null) {
    // Bail early if the mouse handler does not change.
    if (this._mouseHandler === value) {
      return;
    }

    // Release the mouse.
    this._releaseMouse();

    // Update the internal mouse handler.
    this._mouseHandler = value;
  }

  /**
   * Get the style for the data grid.
   */
  get style(): DataGrid.Style {
    return this._style;
  }

  /**
   * Set the style for the data grid.
   */
  set style(value: DataGrid.Style) {
    // Bail if the style does not change.
    if (this._style === value) {
      return;
    }

    // Update the internal style.
    this._style = { ...value };

    // Schedule a repaint of the content.
    this.repaintContent();

    // Schedule a repaint of the overlay.
    this.repaintOverlay();
  }

  /**
   * Get the cell renderer map for the data grid.
   */
  get cellRenderers(): RendererMap {
    return this._cellRenderers;
  }

  /**
   * Set the cell renderer map for the data grid.
   */
  set cellRenderers(value: RendererMap) {
    // Bail if the renderer map does not change.
    if (this._cellRenderers === value) {
      return;
    }

    // Disconnect the old map.
    this._cellRenderers.changed.disconnect(this._onRenderersChanged, this);

    // Connect the new map.
    value.changed.connect(this._onRenderersChanged, this);

    // Update the internal renderer map.
    this._cellRenderers = value;

    // Schedule a repaint of the grid content.
    this.repaintContent();
  }

  /**
   * Get the header visibility for the data grid.
   */
  get headerVisibility(): DataGrid.HeaderVisibility {
    return this._headerVisibility;
  }

  /**
   * Set the header visibility for the data grid.
   */
  set headerVisibility(value: DataGrid.HeaderVisibility) {
    // Bail if the visibility does not change.
    if (this._headerVisibility === value) {
      return;
    }

    // Update the internal visibility.
    this._headerVisibility = value;

    // Sync the viewport.
    this._syncViewport();
  }

  /**
   * Get the default sizes for the various sections of the data grid.
   */
  get defaultSizes(): DataGrid.DefaultSizes {
    let rowHeight = this._rowSections.defaultSize;
    let columnWidth = this._columnSections.defaultSize;
    let rowHeaderWidth = this._rowHeaderSections.defaultSize;
    let columnHeaderHeight = this._columnHeaderSections.defaultSize;
    return { rowHeight, columnWidth, rowHeaderWidth, columnHeaderHeight };
  }

  /**
   * Set the default sizes for the various sections of the data grid.
   */
  set defaultSizes(value: DataGrid.DefaultSizes) {
    // Update the section default sizes.
    this._rowSections.defaultSize = value.rowHeight;
    this._columnSections.defaultSize = value.columnWidth;
    this._rowHeaderSections.defaultSize = value.rowHeaderWidth;
    this._columnHeaderSections.defaultSize = value.columnHeaderHeight;

    // Sync the viewport.
    this._syncViewport();
  }

  /**
   * Get the minimum sizes for the various sections of the data grid.
   */
  get minimumSizes(): DataGrid.DefaultSizes {
    let rowHeight = this._rowSections.minimumSize;
    let columnWidth = this._columnSections.minimumSize;
    let rowHeaderWidth = this._rowHeaderSections.minimumSize;
    let columnHeaderHeight = this._columnHeaderSections.minimumSize;
    return { rowHeight, columnWidth, rowHeaderWidth, columnHeaderHeight };
  }

  /**
   * Set the minimum sizes for the various sections of the data grid.
   */
  set minimumSizes(value: DataGrid.DefaultSizes) {
    // Update the section default sizes.
    this._rowSections.minimumSize = value.rowHeight;
    this._columnSections.minimumSize = value.columnWidth;
    this._rowHeaderSections.minimumSize = value.rowHeaderWidth;
    this._columnHeaderSections.minimumSize = value.columnHeaderHeight;

    // Sync the viewport.
    this._syncViewport();
  }

  /**
   * Get the copy configuration for the data grid.
   */
  get copyConfig(): DataGrid.CopyConfig {
    return this._copyConfig;
  }

  /**
   * Set the copy configuration for the data grid.
   */
  set copyConfig(value: DataGrid.CopyConfig) {
    this._copyConfig = value;
  }

  /**
   * Get whether the last row is stretched.
   */
  get stretchLastRow(): boolean {
    return this._stretchLastRow;
  }

  /**
   * Set whether the last row is stretched.
   */
  set stretchLastRow(value: boolean) {
    // Bail early if the value does not change.
    if (value === this._stretchLastRow) {
      return;
    }

    // Update the internal value.
    this._stretchLastRow = value;

    // Sync the viewport
    this._syncViewport();
  }

  /**
   * Get whether the last column is stretched.
   */
  get stretchLastColumn(): boolean {
    return this._stretchLastColumn;
  }

  /**
   * Set whether the last column is stretched.
   */
  set stretchLastColumn(value: boolean) {
    // Bail early if the value does not change.
    if (value === this._stretchLastColumn) {
      return;
    }

    // Update the internal value.
    this._stretchLastColumn = value;

    // Sync the viewport
    this._syncViewport();
  }

  /**
   * The virtual width of the row headers.
   */
  get headerWidth(): number {
    if (this._headerVisibility === 'none') {
      return 0;
    }
    if (this._headerVisibility === 'column') {
      return 0;
    }
    return this._rowHeaderSections.length;
  }

  /**
   * The virtual height of the column headers.
   */
  get headerHeight(): number {
    if (this._headerVisibility === 'none') {
      return 0;
    }
    if (this._headerVisibility === 'row') {
      return 0;
    }
    return this._columnHeaderSections.length;
  }

  /**
   * The virtual width of the grid body.
   *
   * #### Notes
   * This does *not* account for a stretched last column.
   */
  get bodyWidth(): number {
    return this._columnSections.length;
  }

  /**
   * The virtual height of the grid body.
   *
   * #### Notes
   * This does *not* account for a stretched last row.
   */
  get bodyHeight(): number {
    return this._rowSections.length;
  }

  /**
   * The virtual width of the entire grid.
   *
   * #### Notes
   * This does *not* account for a stretched last column.
   */
  get totalWidth(): number {
    return this.headerWidth + this.bodyWidth;
  }

  /**
   * The virtual height of the entire grid.
   *
   * #### Notes
   * This does *not* account for a stretched last row.
   */
  get totalHeight(): number {
    return this.headerHeight + this.bodyHeight;
  }

  /**
   * The actual width of the viewport.
   */
  get viewportWidth(): number {
    return this._viewportWidth;
  }

  /**
   * The actual height of the viewport.
   */
  get viewportHeight(): number {
    return this._viewportHeight;
  }

  /**
   * The width of the visible portion of the grid body.
   */
  get pageWidth(): number {
    return Math.max(0, this.viewportWidth - this.headerWidth);
  }

  /**
   * The height of the visible portion of the grid body.
   */
  get pageHeight(): number {
    return Math.max(0, this.viewportHeight - this.headerHeight);
  }

  /**
   * The current scroll X position of the viewport.
   */
  get scrollX(): number {
    return this._hScrollBar.value;
  }

  /**
   * The current scroll Y position of the viewport.
   */
  get scrollY(): number {
    return this._vScrollBar.value;
  }

  /**
   * The maximum scroll X position for the grid.
   */
  get maxScrollX(): number {
    return Math.max(0, this.bodyWidth - this.pageWidth - 1);
  }

  /**
   * The maximum scroll Y position for the grid.
   */
  get maxScrollY(): number {
    return Math.max(0, this.bodyHeight - this.pageHeight - 1);
  }

  /**
   * The viewport widget for the data grid.
   */
  get viewport(): Widget {
    return this._viewport;
  }

  /**
   * The cell editor controller object for the data grid.
   */
  get editorController(): ICellEditorController | null {
    return this._editorController;
  }

  set editorController(controller: ICellEditorController | null) {
    this._editorController = controller;
  }

  /**
   * Whether the cell editing is enabled for the data grid.
   */
  get editingEnabled(): boolean {
    return this._editingEnabled;
  }

  set editingEnabled(enabled: boolean) {
    this._editingEnabled = enabled;
  }

  /**
   * Whether the grid cells are editable.
   *
   * `editingEnabled` flag must be on and grid must have required
   * selection model, editor controller and data model properties.
   */
  get editable(): boolean {
    return (
      this._editingEnabled &&
      this._selectionModel !== null &&
      this._editorController !== null &&
      this.dataModel instanceof MutableDataModel
    );
  }

  /**
   * The rendering context for painting the data grid.
   */
  protected get canvasGC(): CanvasRenderingContext2D {
    return this._canvasGC;
  }

  /**
   * The row sections of the data grid.
   */
  protected get rowSections(): SectionList {
    return this._rowSections;
  }

  /**
   * The column sections of the data grid.
   */
  protected get columnSections(): SectionList {
    return this._columnSections;
  }

  /**
   * The row header sections of the data grid.
   */
  protected get rowHeaderSections(): SectionList {
    return this._rowHeaderSections;
  }

  /**
   * The column header sections of the data grid.
   */
  protected get columnHeaderSections(): SectionList {
    return this._columnHeaderSections;
  }

  /**
   * Scroll the grid to the specified row.
   *
   * @param row - The row index of the cell.
   *
   * #### Notes
   * This is a no-op if the row is already visible.
   */
  scrollToRow(row: number): void {
    // Fetch the row count.
    let nr = this._rowSections.count;

    // Bail early if there is no content.
    if (nr === 0) {
      return;
    }

    // Floor the row index.
    row = Math.floor(row);

    // Clamp the row index.
    row = Math.max(0, Math.min(row, nr - 1));

    // Get the virtual bounds of the row.
    let y1 = this._rowSections.offsetOf(row);
    let y2 = this._rowSections.extentOf(row);

    // Get the virtual bounds of the viewport.
    let vy1 = this._scrollY;
    let vy2 = this._scrollY + this.pageHeight - 1;

    // Set up the delta variables.
    let dy = 0;

    // Compute the delta Y scroll.
    if (y1 < vy1) {
      dy = y1 - vy1 - 10;
    } else if (y2 > vy2) {
      dy = y2 - vy2 + 10;
    }

    // Bail early if no scroll is needed.
    if (dy === 0) {
      return;
    }

    // Scroll by the computed delta.
    this.scrollBy(0, dy);
  }

  /**
   * Scroll the grid to the specified column.
   *
   * @param column - The column index of the cell.
   *
   * #### Notes
   * This is a no-op if the column is already visible.
   */
  scrollToColumn(column: number): void {
    // Fetch the column count.
    let nc = this._columnSections.count;

    // Bail early if there is no content.
    if (nc === 0) {
      return;
    }

    // Floor the column index.
    column = Math.floor(column);

    // Clamp the column index.
    column = Math.max(0, Math.min(column, nc - 1));

    // Get the virtual bounds of the column.
    let x1 = this._columnSections.offsetOf(column);
    let x2 = this._columnSections.extentOf(column);

    // Get the virtual bounds of the viewport.
    let vx1 = this._scrollX;
    let vx2 = this._scrollX + this.pageWidth - 1;

    // Set up the delta variables.
    let dx = 0;

    // Compute the delta X scroll.
    if (x1 < vx1) {
      dx = x1 - vx1 - 10;
    } else if (x2 > vx2) {
      dx = x2 - vx2 + 10;
    }

    // Bail early if no scroll is needed.
    if (dx === 0) {
      return;
    }

    // Scroll by the computed delta.
    this.scrollBy(dx, 0);
  }

  /**
   * Scroll the grid to the specified cell.
   *
   * @param row - The row index of the cell.
   *
   * @param column - The column index of the cell.
   *
   * #### Notes
   * This is a no-op if the cell is already visible.
   */
  scrollToCell(row: number, column: number): void {
    // Fetch the row and column count.
    let nr = this._rowSections.count;
    let nc = this._columnSections.count;

    // Bail early if there is no content.
    if (nr === 0 || nc === 0) {
      return;
    }

    // Floor the cell index.
    row = Math.floor(row);
    column = Math.floor(column);

    // Clamp the cell index.
    row = Math.max(0, Math.min(row, nr - 1));
    column = Math.max(0, Math.min(column, nc - 1));

    // Get the virtual bounds of the cell.
    let x1 = this._columnSections.offsetOf(column);
    let x2 = this._columnSections.extentOf(column);
    let y1 = this._rowSections.offsetOf(row);
    let y2 = this._rowSections.extentOf(row);

    // Get the virtual bounds of the viewport.
    let vx1 = this._scrollX;
    let vx2 = this._scrollX + this.pageWidth - 1;
    let vy1 = this._scrollY;
    let vy2 = this._scrollY + this.pageHeight - 1;

    // Set up the delta variables.
    let dx = 0;
    let dy = 0;

    // Compute the delta X scroll.
    if (x1 < vx1) {
      dx = x1 - vx1 - 10;
    } else if (x2 > vx2) {
      dx = x2 - vx2 + 10;
    }

    // Compute the delta Y scroll.
    if (y1 < vy1) {
      dy = y1 - vy1 - 10;
    } else if (y2 > vy2) {
      dy = y2 - vy2 + 10;
    }

    // Bail early if no scroll is needed.
    if (dx === 0 && dy === 0) {
      return;
    }

    // Scroll by the computed delta.
    this.scrollBy(dx, dy);
  }

  /**
   * Move cursor down/up/left/right while making sure it remains
   * within the bounds of selected rectangles
   *
   * @param direction - The direction of the movement.
   */
  moveCursor(direction: SelectionModel.CursorMoveDirection): void {
    // Bail early if there is no selection
    if (
      !this.dataModel ||
      !this._selectionModel ||
      this._selectionModel.isEmpty
    ) {
      return;
    }

    const iter = this._selectionModel.selections();
    const onlyOne = iter.next() && !iter.next();

    // if there is a single selection that is a single cell selection
    // then move the selection and cursor within grid bounds
    if (onlyOne) {
      const currentSel = this._selectionModel.currentSelection()!;
      if (currentSel.r1 === currentSel.r2 && currentSel.c1 === currentSel.c2) {
        const dr = direction === 'down' ? 1 : direction === 'up' ? -1 : 0;
        const dc = direction === 'right' ? 1 : direction === 'left' ? -1 : 0;
        let newRow = currentSel.r1 + dr;
        let newColumn = currentSel.c1 + dc;
        const rowCount = this.dataModel.rowCount('body');
        const columnCount = this.dataModel.columnCount('body');
        if (newRow >= rowCount) {
          newRow = 0;
          newColumn += 1;
        } else if (newRow === -1) {
          newRow = rowCount - 1;
          newColumn -= 1;
        }
        if (newColumn >= columnCount) {
          newColumn = 0;
          newRow += 1;
          if (newRow >= rowCount) {
            newRow = 0;
          }
        } else if (newColumn === -1) {
          newColumn = columnCount - 1;
          newRow -= 1;
          if (newRow === -1) {
            newRow = rowCount - 1;
          }
        }

        this._selectionModel.select({
          r1: newRow,
          c1: newColumn,
          r2: newRow,
          c2: newColumn,
          cursorRow: newRow,
          cursorColumn: newColumn,
          clear: 'all'
        });

        return;
      }
    }

    // if there are multiple selections, move cursor
    // within selection rectangles
    this._selectionModel.moveCursorWithinSelections(direction);
  }

  /**
   * Scroll the grid to the current cursor position.
   *
   * #### Notes
   * This is a no-op if the cursor is already visible or
   * if there is no selection model installed on the grid.
   */
  scrollToCursor(): void {
    // Bail early if there is no selection model.
    if (!this._selectionModel) {
      return;
    }

    // Fetch the cursor row and column.
    let row = this._selectionModel.cursorRow;
    let column = this._selectionModel.cursorColumn;

    // Scroll to the cursor cell.
    this.scrollToCell(row, column);
  }

  /**
   * Scroll the viewport by the specified amount.
   *
   * @param dx - The X scroll amount.
   *
   * @param dy - The Y scroll amount.
   */
  scrollBy(dx: number, dy: number): void {
    this.scrollTo(this.scrollX + dx, this.scrollY + dy);
  }

  /**
   * Scroll the viewport by one page.
   *
   * @param dir - The desired direction of the scroll.
   */
  scrollByPage(dir: 'up' | 'down' | 'left' | 'right'): void {
    let dx = 0;
    let dy = 0;
    switch (dir) {
      case 'up':
        dy = -this.pageHeight;
        break;
      case 'down':
        dy = this.pageHeight;
        break;
      case 'left':
        dx = -this.pageWidth;
        break;
      case 'right':
        dx = this.pageWidth;
        break;
      default:
        throw 'unreachable';
    }
    this.scrollTo(this.scrollX + dx, this.scrollY + dy);
  }

  /**
   * Scroll the viewport by one cell-aligned step.
   *
   * @param dir - The desired direction of the scroll.
   */
  scrollByStep(dir: 'up' | 'down' | 'left' | 'right'): void {
    let r: number;
    let c: number;
    let x = this.scrollX;
    let y = this.scrollY;
    let rows = this._rowSections;
    let columns = this._columnSections;
    switch (dir) {
      case 'up':
        r = rows.indexOf(y - 1);
        y = r < 0 ? y : rows.offsetOf(r);
        break;
      case 'down':
        r = rows.indexOf(y);
        y = r < 0 ? y : rows.offsetOf(r) + rows.sizeOf(r);
        break;
      case 'left':
        c = columns.indexOf(x - 1);
        x = c < 0 ? x : columns.offsetOf(c);
        break;
      case 'right':
        c = columns.indexOf(x);
        x = c < 0 ? x : columns.offsetOf(c) + columns.sizeOf(c);
        break;
      default:
        throw 'unreachable';
    }
    this.scrollTo(x, y);
  }

  /**
   * Scroll to the specified offset position.
   *
   * @param x - The desired X position.
   *
   * @param y - The desired Y position.
   */
  scrollTo(x: number, y: number): void {
    // Floor and clamp the position to the allowable range.
    x = Math.max(0, Math.min(Math.floor(x), this.maxScrollX));
    y = Math.max(0, Math.min(Math.floor(y), this.maxScrollY));

    // Update the scroll bar values with the desired position.
    this._hScrollBar.value = x;
    this._vScrollBar.value = y;

    // Post a scroll request message to the viewport.
    MessageLoop.postMessage(this._viewport, Private.ScrollRequest);
  }

  /**
   * Get the row count for a particular region in the data grid.
   *
   * @param region - The row region of interest.
   *
   * @returns The row count for the specified region.
   */
  rowCount(region: DataModel.RowRegion): number {
    let count: number;
    if (region === 'body') {
      count = this._rowSections.count;
    } else {
      count = this._columnHeaderSections.count;
    }
    return count;
  }

  /**
   * Get the column count for a particular region in the data grid.
   *
   * @param region - The column region of interest.
   *
   * @returns The column count for the specified region.
   */
  columnCount(region: DataModel.ColumnRegion): number {
    let count: number;
    if (region === 'body') {
      count = this._columnSections.count;
    } else {
      count = this._rowHeaderSections.count;
    }
    return count;
  }

  /**
   * Get the row at a virtual offset in the data grid.
   *
   * @param region - The region which holds the row of interest.
   *
   * @param offset - The virtual offset of the row of interest.
   *
   * @returns The index of the row, or `-1` if the offset is out of range.
   *
   * #### Notes
   * This method accounts for a stretched last row.
   */
  rowAt(region: DataModel.RowRegion, offset: number): number {
    // Bail early if the offset is negative.
    if (offset < 0) {
      return -1;
    }

    // Return early for the column header region.
    if (region === 'column-header') {
      return this._columnHeaderSections.indexOf(offset);
    }

    // Fetch the index.
    let index = this._rowSections.indexOf(offset);

    // Return early if the section is found.
    if (index >= 0) {
      return index;
    }

    // Bail early if the last row is not stretched.
    if (!this._stretchLastRow) {
      return -1;
    }

    // Fetch the geometry.
    let bh = this.bodyHeight;
    let ph = this.pageHeight;

    // Bail early if no row stretching is required.
    if (ph <= bh) {
      return -1;
    }

    // Bail early if the offset is out of bounds.
    if (offset >= ph) {
      return -1;
    }

    // Otherwise, return the last row.
    return this._rowSections.count - 1;
  }

  /**
   * Get the column at a virtual offset in the data grid.
   *
   * @param region - The region which holds the column of interest.
   *
   * @param offset - The virtual offset of the column of interest.
   *
   * @returns The index of the column, or `-1` if the offset is out of range.
   *
   * #### Notes
   * This method accounts for a stretched last column.
   */
  columnAt(region: DataModel.ColumnRegion, offset: number): number {
    if (offset < 0) {
      return -1;
    }

    // Return early for the row header region.
    if (region === 'row-header') {
      return this._rowHeaderSections.indexOf(offset);
    }

    // Fetch the index.
    let index = this._columnSections.indexOf(offset);

    // Return early if the section is found.
    if (index >= 0) {
      return index;
    }

    // Bail early if the last column is not stretched.
    if (!this._stretchLastColumn) {
      return -1;
    }

    // Fetch the geometry.
    let bw = this.bodyWidth;
    let pw = this.pageWidth;

    // Bail early if no column stretching is required.
    if (pw <= bw) {
      return -1;
    }

    // Bail early if the offset is out of bounds.
    if (offset >= pw) {
      return -1;
    }

    // Otherwise, return the last column.
    return this._columnSections.count - 1;
  }

  /**
   * Get the offset of a row in the data grid.
   *
   * @param region - The region which holds the row of interest.
   *
   * @param index - The index of the row of interest.
   *
   * @returns The offset of the row, or `-1` if the index is out of range.
   *
   * #### Notes
   * A stretched last row has no effect on the return value.
   */
  rowOffset(region: DataModel.RowRegion, index: number): number {
    let offset: number;
    if (region === 'body') {
      offset = this._rowSections.offsetOf(index);
    } else {
      offset = this._columnHeaderSections.offsetOf(index);
    }
    return offset;
  }

  /**
   * Get the offset of a column in the data grid.
   *
   * @param region - The region which holds the column of interest.
   *
   * @param index - The index of the column of interest.
   *
   * @returns The offset of the column, or `-1` if the index is out of range.
   *
   * #### Notes
   * A stretched last column has no effect on the return value.
   */
  columnOffset(region: DataModel.ColumnRegion, index: number): number {
    let offset: number;
    if (region === 'body') {
      offset = this._columnSections.offsetOf(index);
    } else {
      offset = this._rowHeaderSections.offsetOf(index);
    }
    return offset;
  }

  /**
   * Get the size of a row in the data grid.
   *
   * @param region - The region which holds the row of interest.
   *
   * @param index - The index of the row of interest.
   *
   * @returns The size of the row, or `-1` if the index is out of range.
   *
   * #### Notes
   * This method accounts for a stretched last row.
   */
  rowSize(region: DataModel.RowRegion, index: number): number {
    // Return early for the column header region.
    if (region === 'column-header') {
      return this._columnHeaderSections.sizeOf(index);
    }

    // Fetch the row size.
    let size = this._rowSections.sizeOf(index);

    // Bail early if the index is out of bounds.
    if (size < 0) {
      return size;
    }

    // Return early if the last row is not stretched.
    if (!this._stretchLastRow) {
      return size;
    }

    // Return early if its not the last row.
    if (index < this._rowSections.count - 1) {
      return size;
    }

    // Fetch the geometry.
    let bh = this.bodyHeight;
    let ph = this.pageHeight;

    // Return early if no stretching is needed.
    if (ph <= bh) {
      return size;
    }

    // Return the adjusted size.
    return size + (ph - bh);
  }

  /**
   * Get the size of a column in the data grid.
   *
   * @param region - The region which holds the column of interest.
   *
   * @param index - The index of the column of interest.
   *
   * @returns The size of the column, or `-1` if the index is out of range.
   *
   * #### Notes
   * This method accounts for a stretched last column.
   */
  columnSize(region: DataModel.ColumnRegion, index: number): number {
    // Return early for the row header region.
    if (region === 'row-header') {
      return this._rowHeaderSections.sizeOf(index);
    }

    // Fetch the column size.
    let size = this._columnSections.sizeOf(index);

    // Bail early if the index is out of bounds.
    if (size < 0) {
      return size;
    }

    // Return early if the last column is not stretched.
    if (!this._stretchLastColumn) {
      return size;
    }

    // Return early if its not the last column.
    if (index < this._columnSections.count - 1) {
      return size;
    }

    // Fetch the geometry.
    let bw = this.bodyWidth;
    let pw = this.pageWidth;

    // Return early if no stretching is needed.
    if (pw <= bw) {
      return size;
    }

    // Return the adjusted size.
    return size + (pw - bw);
  }

  /**
   * Resize a row in the data grid.
   *
   * @param region - The region which holds the row of interest.
   *
   * @param index - The index of the row of interest.
   *
   * @param size - The desired size of the row.
   */
  resizeRow(region: DataModel.RowRegion, index: number, size: number): void {
    let msg = new Private.RowResizeRequest(region, index, size);
    MessageLoop.postMessage(this._viewport, msg);
  }

  /**
   * Resize a column in the data grid.
   *
   * @param region - The region which holds the column of interest.
   *
   * @param index - The index of the column of interest.
   *
   * @param size - The desired size of the column.
   */
  resizeColumn(
    region: DataModel.ColumnRegion,
    index: number,
    size: number | null
  ): void {
    let msg = new Private.ColumnResizeRequest(region, index, size);
    MessageLoop.postMessage(this._viewport, msg);
  }

  /**
   * Reset modified rows to their default size.
   *
   * @param region - The row region of interest.
   */
  resetRows(region: DataModel.RowRegion | 'all'): void {
    switch (region) {
      case 'all':
        this._rowSections.reset();
        this._columnHeaderSections.reset();
        break;
      case 'body':
        this._rowSections.reset();
        break;
      case 'column-header':
        this._columnHeaderSections.reset();
        break;
      default:
        throw 'unreachable';
    }
    this.repaintContent();
    this.repaintOverlay();
  }

  /**
   * Reset modified columns to their default size.
   *
   * @param region - The column region of interest.
   */
  resetColumns(region: DataModel.ColumnRegion | 'all'): void {
    switch (region) {
      case 'all':
        this._columnSections.reset();
        this._rowHeaderSections.reset();
        break;
      case 'body':
        this._columnSections.reset();
        break;
      case 'row-header':
        this._rowHeaderSections.reset();
        break;
      default:
        throw 'unreachable';
    }
    this.repaintContent();
    this.repaintOverlay();
  }

  /**
   * Auto sizes column-header widths based on their text content.
   * @param area which area to resize: 'body', 'row-header' or 'all'.
   * @param padding padding added to resized columns (pixels).
   * @param numCols specify cap on the number of column resizes (optional).
   */
  fitColumnNames(
    area: DataGrid.ColumnFitType = 'all',
    padding: number = 15,
    numCols?: number
  ): void {
    // Attempt resizing only if a data model is present.
    if (this.dataModel) {
      // Tracking remaining columns to be resized if numCols arg passed.
      let colsRemaining =
        numCols === undefined || numCols < 0 ? undefined : numCols;

      if (area === 'row-header' || area === 'all') {
        // Respecting any column resize cap, if one has been passed.
        if (colsRemaining !== undefined) {
          const rowColumnCount = this.dataModel.columnCount('row-header');
          /*
            If we have more row-header columns than columns available
            for resize, resize only remaining columns as per allowance
            and set remaining resize allowance number to 0.
          */
          if (colsRemaining - rowColumnCount < 0) {
            this._fitRowColumnHeaders(this.dataModel, padding, colsRemaining);
            colsRemaining = 0;
          } else {
            /*
              Otherwise the entire row-header column count can be resized.
              Resize all row-header columns and subtract from remaining
              column resize allowance.
            */
            this._fitRowColumnHeaders(this.dataModel, padding, rowColumnCount);
            colsRemaining = colsRemaining - rowColumnCount;
          }
        } else {
          // No column resize cap passed - resizing all columns.
          this._fitRowColumnHeaders(this.dataModel, padding);
        }
      }

      if (area === 'body' || area === 'all') {
        // Respecting any column resize cap, if one has been passed.
        if (colsRemaining !== undefined) {
          const bodyColumnCount = this.dataModel.columnCount('body');
          /*
            If we have more body columns than columns available
            for resize, resize only remaining columns as per allowance
            and set remaining resize allowance number to 0.
          */
          if (colsRemaining - bodyColumnCount < 0) {
            this._fitBodyColumnHeaders(this.dataModel, padding, colsRemaining);
          } else {
            /*
              Otherwise the entire body column count can be resized.
              Resize based on the smallest number between remaining
              resize allowance and body column count.
            */
            this._fitBodyColumnHeaders(
              this.dataModel,
              padding,
              Math.min(colsRemaining, bodyColumnCount)
            );
          }
        } else {
          // No column resize cap passed - resizing all columns.
          this._fitBodyColumnHeaders(this.dataModel, padding);
        }
      }
    }
  }

  /**
   * Map a client position to local viewport coordinates.
   *
   * @param clientX - The client X position of the mouse.
   *
   * @param clientY - The client Y position of the mouse.
   *
   * @returns The local viewport coordinates for the position.
   */
  mapToLocal(clientX: number, clientY: number): { lx: number; ly: number } {
    // Fetch the viewport rect.
    let rect = this._viewport.node.getBoundingClientRect();

    // Extract the rect coordinates.
    let { left, top } = rect;

    // Round the rect coordinates for sub-pixel positioning.
    left = Math.floor(left);
    top = Math.floor(top);

    // Convert to local coordinates.
    let lx = clientX - left;
    let ly = clientY - top;

    // Return the local coordinates.
    return { lx, ly };
  }

  /**
   * Map a client position to virtual grid coordinates.
   *
   * @param clientX - The client X position of the mouse.
   *
   * @param clientY - The client Y position of the mouse.
   *
   * @returns The virtual grid coordinates for the position.
   */
  mapToVirtual(clientX: number, clientY: number): { vx: number; vy: number } {
    // Convert to local coordiates.
    let { lx, ly } = this.mapToLocal(clientX, clientY);

    // Convert to virtual coordinates.
    let vx = lx + this.scrollX - this.headerWidth;
    let vy = ly + this.scrollY - this.headerHeight;

    // Return the local coordinates.
    return { vx, vy };
  }

  /**
   * Hit test the viewport for the given client position.
   *
   * @param clientX - The client X position of the mouse.
   *
   * @param clientY - The client Y position of the mouse.
   *
   * @returns The hit test result, or `null` if the client
   *   position is out of bounds.
   *
   * #### Notes
   * This method accounts for a stretched last row and/or column.
   */
  hitTest(clientX: number, clientY: number): DataGrid.HitTestResult {
    // Convert the mouse position into local coordinates.
    let { lx, ly } = this.mapToLocal(clientX, clientY);

    // Fetch the header and body dimensions.
    let hw = this.headerWidth;
    let hh = this.headerHeight;
    let bw = this.bodyWidth;
    let bh = this.bodyHeight;
    let ph = this.pageHeight;
    let pw = this.pageWidth;

    // Adjust the body width for a stretched last column.
    if (this._stretchLastColumn && pw > bw) {
      bw = pw;
    }

    // Adjust the body height for a stretched last row.
    if (this._stretchLastRow && ph > bh) {
      bh = ph;
    }

    // Check for a corner header hit.
    if (lx >= 0 && lx < hw && ly >= 0 && ly < hh) {
      // Convert to unscrolled virtual coordinates.
      let vx = lx;
      let vy = ly;

      // Fetch the row and column index.
      let row = this.rowAt('column-header', vy);
      let column = this.columnAt('row-header', vx);

      // Fetch the cell offset position.
      let ox = this.columnOffset('row-header', column);
      let oy = this.rowOffset('column-header', row);

      // Fetch cell width and height.
      let width = this.columnSize('row-header', column);
      let height = this.rowSize('column-header', row);

      // Compute the leading and trailing positions.
      let x = vx - ox;
      let y = vy - oy;

      // Return the hit test result.
      return { region: 'corner-header', row, column, x, y, width, height };
    }

    // Check for a column header hit.
    if (ly >= 0 && ly < hh && lx >= 0 && lx < hw + bw) {
      // Convert to unscrolled virtual coordinates.
      let vx = lx + this._scrollX - hw;
      let vy = ly;

      // Fetch the row and column index.
      let row = this.rowAt('column-header', vy);
      let column = this.columnAt('body', vx);

      // Fetch the cell offset position.
      let ox = this.columnOffset('body', column);
      let oy = this.rowOffset('column-header', row);

      // Fetch the cell width and height.
      let width = this.columnSize('body', column);
      let height = this.rowSize('column-header', row);

      // Compute the leading and trailing positions.
      let x = vx - ox;
      let y = vy - oy;

      // Return the hit test result.
      return { region: 'column-header', row, column, x, y, width, height };
    }

    // Check for a row header hit.
    if (lx >= 0 && lx < hw && ly >= 0 && ly < hh + bh) {
      // Convert to unscrolled virtual coordinates.
      let vx = lx;
      let vy = ly + this._scrollY - hh;

      // Fetch the row and column index.
      let row = this.rowAt('body', vy);
      let column = this.columnAt('row-header', vx);

      // Fetch the cell offset position.
      let ox = this.columnOffset('row-header', column);
      let oy = this.rowOffset('body', row);

      // Fetch the cell width and height.
      let width = this.columnSize('row-header', column);
      let height = this.rowSize('body', row);

      // Compute the leading and trailing positions.
      let x = vx - ox;
      let y = vy - oy;

      // Return the hit test result.
      return { region: 'row-header', row, column, x, y, width, height };
    }

    // Check for a body hit.
    if (lx >= hw && lx < hw + bw && ly >= hh && ly < hh + bh) {
      // Convert to unscrolled virtual coordinates.
      let vx = lx + this._scrollX - hw;
      let vy = ly + this._scrollY - hh;

      // Fetch the row and column index.
      let row = this.rowAt('body', vy);
      let column = this.columnAt('body', vx);

      // Fetch the cell offset position.
      let ox = this.columnOffset('body', column);
      let oy = this.rowOffset('body', row);

      // Fetch the cell width and height.
      let width = this.columnSize('body', column);
      let height = this.rowSize('body', row);

      // Compute the part coordinates.
      let x = vx - ox;
      let y = vy - oy;

      // Return the result.
      return { region: 'body', row, column, x, y, width, height };
    }

    // Otherwise, it's a void space hit.
    let row = -1;
    let column = -1;
    let x = -1;
    let y = -1;
    let width = -1;
    let height = -1;

    // Return the hit test result.
    return { region: 'void', row, column, x, y, width, height };
  }

  /**
   * Copy the current selection to the system clipboard.
   *
   * #### Notes
   * The grid must have a data model and a selection model.
   *
   * The behavior can be configured via `DataGrid.copyConfig`.
   */
  copyToClipboard(): void {
    // Fetch the data model.
    let dataModel = this._dataModel;

    // Bail early if there is no data model.
    if (!dataModel) {
      return;
    }

    // Fetch the selection model.
    let selectionModel = this._selectionModel;

    // Bail early if there is no selection model.
    if (!selectionModel) {
      return;
    }

    // Coerce the selections to an array.
    let selections = Array.from(selectionModel.selections());

    // Bail early if there are no selections.
    if (selections.length === 0) {
      return;
    }

    // Alert that multiple selections cannot be copied.
    if (selections.length > 1) {
      alert('Cannot copy multiple grid selections.');
      return;
    }

    // Fetch the model counts.
    let br = dataModel.rowCount('body');
    let bc = dataModel.columnCount('body');

    // Bail early if there is nothing to copy.
    if (br === 0 || bc === 0) {
      return;
    }

    // Unpack the selection.
    let { r1, c1, r2, c2 } = selections[0];

    // Clamp the selection to the model bounds.
    r1 = Math.max(0, Math.min(r1, br - 1));
    c1 = Math.max(0, Math.min(c1, bc - 1));
    r2 = Math.max(0, Math.min(r2, br - 1));
    c2 = Math.max(0, Math.min(c2, bc - 1));

    // Ensure the limits are well-orderd.
    if (r2 < r1) [r1, r2] = [r2, r1];
    if (c2 < c1) [c1, c2] = [c2, c1];

    // Fetch the header counts.
    let rhc = dataModel.columnCount('row-header');
    let chr = dataModel.rowCount('column-header');

    // Unpack the copy config.
    let separator = this._copyConfig.separator;
    let format = this._copyConfig.format;
    let headers = this._copyConfig.headers;
    let warningThreshold = this._copyConfig.warningThreshold;

    // Compute the number of cells to be copied.
    let rowCount = r2 - r1 + 1;
    let colCount = c2 - c1 + 1;
    switch (headers) {
      case 'none':
        rhc = 0;
        chr = 0;
        break;
      case 'row':
        chr = 0;
        colCount += rhc;
        break;
      case 'column':
        rhc = 0;
        rowCount += chr;
        break;
      case 'all':
        rowCount += chr;
        colCount += rhc;
        break;
      default:
        throw 'unreachable';
    }

    // Compute the total cell count.
    let cellCount = rowCount * colCount;

    // Allow the user to cancel a large copy request.
    if (cellCount > warningThreshold) {
      let msg = `Copying ${cellCount} cells may take a while. Continue?`;
      if (!window.confirm(msg)) {
        return;
      }
    }

    // Set up the format args.
    let args = {
      region: 'body' as DataModel.CellRegion,
      row: 0,
      column: 0,
      value: null as any,
      metadata: {} as DataModel.Metadata
    };

    // Allocate the array of rows.
    let rows = new Array<string[]>(rowCount);

    // Iterate over the rows.
    for (let j = 0; j < rowCount; ++j) {
      // Allocate the array of cells.
      let cells = new Array<string>(colCount);

      // Iterate over the columns.
      for (let i = 0; i < colCount; ++i) {
        // Set up the format variables.
        let region: DataModel.CellRegion;
        let row: number;
        let column: number;

        // Populate the format variables.
        if (j < chr && i < rhc) {
          region = 'corner-header';
          row = j;
          column = i;
        } else if (j < chr) {
          region = 'column-header';
          row = j;
          column = i - rhc + c1;
        } else if (i < rhc) {
          region = 'row-header';
          row = j - chr + r1;
          column = i;
        } else {
          region = 'body';
          row = j - chr + r1;
          column = i - rhc + c1;
        }

        // Populate the format args.
        args.region = region;
        args.row = row;
        args.column = column;
        args.value = dataModel.data(region, row, column);
        args.metadata = dataModel.metadata(region, row, column);

        // Format the cell.
        cells[i] = format(args);
      }

      // Save the row of cells.
      rows[j] = cells;
    }

    // Convert the cells into lines.
    let lines = rows.map(cells => cells.join(separator));

    // Convert the lines into text.
    let text = lines.join('\n');

    // Copy the text to the clipboard.
    ClipboardExt.copyText(text);
  }

  /**
   * Process a message sent to the widget.
   *
   * @param msg - The message sent to the widget.
   */
  processMessage(msg: Message): void {
    // Ignore child show/hide messages. The data grid controls the
    // visibility of its children, and will manually dispatch the
    // fit-request messages as a result of visibility change.
    if (msg.type === 'child-shown' || msg.type === 'child-hidden') {
      return;
    }

    // Recompute the scroll bar minimums before the layout refits.
    if (msg.type === 'fit-request') {
      let vsbLimits = ElementExt.sizeLimits(this._vScrollBar.node);
      let hsbLimits = ElementExt.sizeLimits(this._hScrollBar.node);
      this._vScrollBarMinWidth = vsbLimits.minWidth;
      this._hScrollBarMinHeight = hsbLimits.minHeight;
    }

    // Process all other messages as normal.
    super.processMessage(msg);
  }

  /**
   * Intercept a message sent to a message handler.
   *
   * @param handler - The target handler of the message.
   *
   * @param msg - The message to be sent to the handler.
   *
   * @returns `true` if the message should continue to be processed
   *   as normal, or `false` if processing should cease immediately.
   */
  messageHook(handler: IMessageHandler, msg: Message): boolean {
    // Process viewport messages.
    if (handler === this._viewport) {
      this._processViewportMessage(msg);
      return true;
    }

    // Process horizontal scroll bar messages.
    if (handler === this._hScrollBar && msg.type === 'activate-request') {
      this.activate();
      return false;
    }

    // Process vertical scroll bar messages.
    if (handler === this._vScrollBar && msg.type === 'activate-request') {
      this.activate();
      return false;
    }

    // Ignore all other messages.
    return true;
  }

  /**
   * Handle the DOM events for the data grid.
   *
   * @param event - The DOM event sent to the data grid.
   *
   * #### Notes
   * This method implements the DOM `EventListener` interface and is
   * called in response to events on the data grid's DOM node. It
   * should not be called directly by user code.
   */
  handleEvent(event: Event): void {
    switch (event.type) {
      case 'keydown':
        this._evtKeyDown(event as KeyboardEvent);
        break;
      case 'mousedown':
        this._evtMouseDown(event as MouseEvent);
        break;
      case 'mousemove':
        this._evtMouseMove(event as MouseEvent);
        break;
      case 'mouseup':
        this._evtMouseUp(event as MouseEvent);
        break;
      case 'dblclick':
        this._evtMouseDoubleClick(event as MouseEvent);
        break;
      case 'mouseleave':
        this._evtMouseLeave(event as MouseEvent);
        break;
      case 'contextmenu':
        this._evtContextMenu(event as MouseEvent);
        break;
      case 'wheel':
        this._evtWheel(event as WheelEvent);
        break;
      case 'resize':
        this._refreshDPI();
        break;
    }
  }

  /**
   * Get the current viewport.
   *
   * @returns The current viewport as row/column coordinates.
   * Returns undefined if the grid is not visible.
   */
  get currentViewport(): DataGrid.IBodyRegion | undefined {
    let width = this.viewport.node.offsetWidth;
    let height = this.viewport.node.offsetHeight;

    width = Math.round(width);
    height = Math.round(height);

    if (width <= 0 || height <= 0) {
      return;
    }

    const contentW = this._columnSections.length - this.scrollX;
    const contentH = this._rowSections.length - this.scrollY;

    const contentX = this.headerWidth;
    const contentY = this.headerHeight;

    const x1 = contentX;
    const y1 = contentY;
    const x2 = Math.min(width - 1, contentX + contentW - 1);
    const y2 = Math.min(height - 1, contentY + contentH - 1);

    const firstRow = this._rowSections.indexOf(y1 - contentY + this.scrollY);
    const firstColumn = this._columnSections.indexOf(
      x1 - contentX + this.scrollX
    );
    const lastRow = this._rowSections.indexOf(y2 - contentY + this.scrollY);
    const lastColumn = this._columnSections.indexOf(
      x2 - contentX + this.scrollX
    );

    return {
      firstRow,
      firstColumn,
      lastRow,
      lastColumn
    };
  }

  /**
   * A message handler invoked on an `'activate-request'` message.
   */
  protected onActivateRequest(msg: Message): void {
    this.viewport.node.focus({ preventScroll: true });
  }

  /**
   * A message handler invoked on a `'before-attach'` message.
   */
  protected onBeforeAttach(msg: Message): void {
    window.addEventListener('resize', this);
    this.node.addEventListener('wheel', this);
    this._viewport.node.addEventListener('keydown', this);
    this._viewport.node.addEventListener('mousedown', this);
    this._viewport.node.addEventListener('mousemove', this);
    this._viewport.node.addEventListener('dblclick', this);
    this._viewport.node.addEventListener('mouseleave', this);
    this._viewport.node.addEventListener('contextmenu', this);
    this.repaintContent();
    this.repaintOverlay();
  }

  /**
   * A message handler invoked on an `'after-detach'` message.
   */
  protected onAfterDetach(msg: Message): void {
    window.removeEventListener('resize', this);
    this.node.removeEventListener('wheel', this);
    this._viewport.node.removeEventListener('keydown', this);
    this._viewport.node.removeEventListener('mousedown', this);
    this._viewport.node.removeEventListener('mousemove', this);
    this._viewport.node.removeEventListener('mouseleave', this);
    this._viewport.node.removeEventListener('dblclick', this);
    this._viewport.node.removeEventListener('contextmenu', this);
    this._releaseMouse();
  }

  /**
   * A message handler invoked on a `'before-show'` message.
   */
  protected onBeforeShow(msg: Message): void {
    this.repaintContent();
    this.repaintOverlay();
  }

  /**
   * A message handler invoked on a `'resize'` message.
   */
  protected onResize(msg: Widget.ResizeMessage): void {
    if (this._editorController) {
      this._editorController.cancel();
    }

    this._syncScrollState();
  }

  /**
   * Schedule a repaint of all of the grid content.
   */
  protected repaintContent(): void {
    let msg = new Private.PaintRequest('all', 0, 0, 0, 0);
    MessageLoop.postMessage(this._viewport, msg);
  }

  /**
   * Schedule a repaint of specific grid content.
   */
  protected repaintRegion(
    region: DataModel.CellRegion,
    r1: number,
    c1: number,
    r2: number,
    c2: number
  ): void {
    let msg = new Private.PaintRequest(region, r1, c1, r2, c2);
    MessageLoop.postMessage(this._viewport, msg);
  }

  /**
   * Schedule a repaint of the overlay.
   */
  protected repaintOverlay(): void {
    MessageLoop.postMessage(this._viewport, Private.OverlayPaintRequest);
  }

  private _getMaxWidthInColumn(
    index: number,
    columnRegion: 'row-header' | 'body'
  ): number | null {
    const dataModel = this.dataModel;

    if (!dataModel) {
      return null;
    }

    const columnHeaderRegion =
      columnRegion == 'row-header' ? 'corner-header' : 'column-header';

    return Math.max(
      this._getMaxWidthInArea(
        dataModel,
        index,
        columnHeaderRegion,
        'column-header'
      ),
      this._getMaxWidthInArea(dataModel, index, columnRegion, 'body')
    );
  }

  private _getMaxWidthInArea(
    dataModel: DataModel,
    index: number,
    region: DataModel.CellRegion,
    rowRegion: DataModel.RowRegion
  ): number {
    const numRows = dataModel.rowCount(rowRegion);
    // Will only allocate up to 1_000_000 elements otherwise performance can tank.
    const configs = Array.from(
      { length: Math.min(numRows, 1_000_000) },
      (_val, idx) => DataGrid._getConfig(dataModel, idx, index, region)
    );

    // Heuristic: Sort by the length of the text to render and only fully calculate the text width
    // for the top 100_000 rows by text length
    if (numRows > 100_000) {
      // Sort by descending length
      configs.sort(x => -this._getTextToRender(x).length);
    }

    let maxWidth = 0;
    for (let i = 0; i < numRows && i < 100_000; ++i) {
      const textWidth = this._getCellTextWidth(configs[i]);
      maxWidth = Math.max(maxWidth, textWidth);
    }

    return maxWidth;
  }

  private static _getConfig(
    dataModel: DataModel,
    row: number,
    col: number,
    location: DataModel.CellRegion
  ): CellRenderer.CellConfig {
    return {
      x: 0,
      y: 0,
      width: 0,
      height: 0,
      region: location,
      row: row,
      column: col,
      value: DataGrid._getCellValue(dataModel, location, row, col),
      metadata: DataGrid._getCellMetadata(dataModel, location, row, col)
    };
  }

  private _getTextToRender(config: CellRenderer.CellConfig): string {
    const renderer = this.cellRenderers.get(config) as TextRenderer;
    return renderer.getText(config);
  }

  private _getCellTextWidth(config: CellRenderer.CellConfig): number {
    // Get the renderer for the given cell.
    const renderer = this.cellRenderers.get(config) as TextRenderer;

    // Use the canvas context to measure the cell's text width
    const gc = this.canvasGC;
    gc.font = CellRenderer.resolveOption(renderer.font, config);
    gc.fillStyle = CellRenderer.resolveOption(renderer.textColor, config);
    gc.textAlign = CellRenderer.resolveOption(
      renderer.horizontalAlignment,
      config
    );
    gc.textBaseline = 'bottom';

    const text = this._getTextToRender(config);

    return gc.measureText(text).width + 2 * renderer.horizontalPadding;
  }

  /**
   * Ensure the canvas is at least the specified size.
   *
   * This method will retain the valid canvas content.
   */
  private _resizeCanvasIfNeeded(width: number, height: number): void {
    // Scale the size by the dpi ratio.
    width = width * this._dpiRatio;
    height = height * this._dpiRatio;

    // Compute the maximum canvas size for the given width and height.
    let maxW = (Math.ceil((width + 1) / 512) + 1) * 512;
    let maxH = (Math.ceil((height + 1) / 512) + 1) * 512;

    // Get the current size of the canvas.
    let curW = this._canvas.width;
    let curH = this._canvas.height;

    // Bail early if the canvas size is within bounds.
    if (curW >= width && curH >= height && curW <= maxW && curH <= maxH) {
      return;
    }

    // Compute the expanded canvas size.
    let expW = maxW - 512;
    let expH = maxH - 512;

    // Set the transforms to the identity matrix.
    this._canvasGC.setTransform(1, 0, 0, 1, 0, 0);
    this._bufferGC.setTransform(1, 0, 0, 1, 0, 0);
    this._overlayGC.setTransform(1, 0, 0, 1, 0, 0);

    // Resize the buffer if needed.
    if (curW < width) {
      this._buffer.width = expW;
    } else if (curW > maxW) {
      this._buffer.width = maxW;
    }

    // Resize the buffer height if needed.
    if (curH < height) {
      this._buffer.height = expH;
    } else if (curH > maxH) {
      this._buffer.height = maxH;
    }

    // Test whether there is content to blit.
    let needBlit = curW > 0 && curH > 0 && width > 0 && height > 0;

    // Copy the valid canvas content into the buffer if needed.
    if (needBlit) {
      this._bufferGC.drawImage(this._canvas, 0, 0);
    }

    // Resize the canvas width if needed.
    if (curW < width) {
      this._canvas.width = expW;
      this._canvas.style.width = `${expW / this._dpiRatio}px`;
    } else if (curW > maxW) {
      this._canvas.width = maxW;
      this._canvas.style.width = `${maxW / this._dpiRatio}px`;
    }

    // Resize the canvas height if needed.
    if (curH < height) {
      this._canvas.height = expH;
      this._canvas.style.height = `${expH / this._dpiRatio}px`;
    } else if (curH > maxH) {
      this._canvas.height = maxH;
      this._canvas.style.height = `${maxH / this._dpiRatio}px`;
    }

    // Copy the valid canvas content from the buffer if needed.
    if (needBlit) {
      this._canvasGC.drawImage(this._buffer, 0, 0);
    }

    // Copy the valid overlay content into the buffer if needed.
    if (needBlit) {
      this._bufferGC.drawImage(this._overlay, 0, 0);
    }

    // Resize the overlay width if needed.
    if (curW < width) {
      this._overlay.width = expW;
      this._overlay.style.width = `${expW / this._dpiRatio}px`;
    } else if (curW > maxW) {
      this._overlay.width = maxW;
      this._overlay.style.width = `${maxW / this._dpiRatio}px`;
    }

    // Resize the overlay height if needed.
    if (curH < height) {
      this._overlay.height = expH;
      this._overlay.style.height = `${expH / this._dpiRatio}px`;
    } else if (curH > maxH) {
      this._overlay.height = maxH;
      this._overlay.style.height = `${maxH / this._dpiRatio}px`;
    }

    // Copy the valid overlay content from the buffer if needed.
    if (needBlit) {
      this._overlayGC.drawImage(this._buffer, 0, 0);
    }
  }

  /**
   * Sync the scroll bars and scroll state with the viewport.
   *
   * #### Notes
   * If the visibility of either scroll bar changes, a synchronous
   * fit-request will be dispatched to the data grid to immediately
   * resize the viewport.
   */
  private _syncScrollState(): void {
    // Fetch the viewport dimensions.
    let bw = this.bodyWidth;
    let bh = this.bodyHeight;
    let pw = this.pageWidth;
    let ph = this.pageHeight;

    // Get the current scroll bar visibility.
    let hasVScroll = !this._vScrollBar.isHidden;
    let hasHScroll = !this._hScrollBar.isHidden;

    // Get the minimum sizes of the scroll bars.
    let vsw = this._vScrollBarMinWidth;
    let hsh = this._hScrollBarMinHeight;

    // Get the page size as if no scroll bars are visible.
    let apw = pw + (hasVScroll ? vsw : 0);
    let aph = ph + (hasHScroll ? hsh : 0);

    // Test whether scroll bars are needed for the adjusted size.
    let needVScroll = aph < bh - 1;
    let needHScroll = apw < bw - 1;

    // Re-test the horizontal scroll if a vertical scroll is needed.
    if (needVScroll && !needHScroll) {
      needHScroll = apw - vsw < bw - 1;
    }

    // Re-test the vertical scroll if a horizontal scroll is needed.
    if (needHScroll && !needVScroll) {
      needVScroll = aph - hsh < bh - 1;
    }

    // If the visibility changes, immediately refit the grid.
    if (needVScroll !== hasVScroll || needHScroll !== hasHScroll) {
      this._vScrollBar.setHidden(!needVScroll);
      this._hScrollBar.setHidden(!needHScroll);
      this._scrollCorner.setHidden(!needVScroll || !needHScroll);
      MessageLoop.sendMessage(this, Widget.Msg.FitRequest);
    }

    // Update the scroll bar limits.
    this._vScrollBar.maximum = this.maxScrollY;
    this._vScrollBar.page = this.pageHeight;
    this._hScrollBar.maximum = this.maxScrollX;
    this._hScrollBar.page = this.pageWidth;

    // Re-clamp the scroll position.
    this._scrollTo(this._scrollX, this._scrollY);
  }

  /**
   * Sync the viewport to the given scroll position.
   *
   * #### Notes
   * This schedules a full repaint and syncs the scroll state.
   */
  private _syncViewport(): void {
    this.repaintContent();
    this.repaintOverlay();
    this._syncScrollState();
  }

  /**
   * Process a message sent to the viewport
   */
  private _processViewportMessage(msg: Message): void {
    switch (msg.type) {
      case 'resize':
        this._onViewportResize(msg as Widget.ResizeMessage);
        break;
      case 'scroll-request':
        this._onViewportScrollRequest(msg);
        break;
      case 'paint-request':
        this._onViewportPaintRequest(msg as Private.PaintRequest);
        break;
      case 'overlay-paint-request':
        this._onViewportOverlayPaintRequest(msg);
        break;
      case 'row-resize-request':
        this._onViewportRowResizeRequest(msg as Private.RowResizeRequest);
        break;
      case 'column-resize-request':
        this._onViewportColumnResizeRequest(msg as Private.ColumnResizeRequest);
        break;
      default:
        break;
    }
  }

  /**
   * A message hook invoked on a viewport `'resize'` message.
   */
  private _onViewportResize(msg: Widget.ResizeMessage): void {
    // Bail early if the viewport is not visible.
    if (!this._viewport.isVisible) {
      return;
    }

    // Unpack the message data.
    let { width, height } = msg;

    // Measure the viewport node if the dimensions are unknown.
    if (width === -1) {
      width = this._viewport.node.offsetWidth;
    }
    if (height === -1) {
      height = this._viewport.node.offsetHeight;
    }

    // Round the dimensions to the nearest pixel.
    width = Math.round(width);
    height = Math.round(height);

    // Get the current size of the viewport.
    let oldWidth = this._viewportWidth;
    let oldHeight = this._viewportHeight;

    // Updated internal viewport size.
    this._viewportWidth = width;
    this._viewportHeight = height;

    // Resize the canvas if needed.
    this._resizeCanvasIfNeeded(width, height);

    // Bail early if there is nothing to paint.
    if (width === 0 || height === 0) {
      return;
    }

    // Paint the whole grid if the old size was zero.
    if (oldWidth === 0 || oldHeight === 0) {
      this.paintContent(0, 0, width, height);
      this._paintOverlay();
      return;
    }

    // Paint the right edge as needed.
    if (this._stretchLastColumn && this.pageWidth > this.bodyWidth) {
      let bx = this._columnSections.offsetOf(this._columnSections.count - 1);
      let x = Math.min(this.headerWidth + bx, oldWidth);
      this.paintContent(x, 0, width - x, height);
    } else if (width > oldWidth) {
      this.paintContent(oldWidth, 0, width - oldWidth + 1, height);
    }

    // Paint the bottom edge as needed.
    if (this._stretchLastRow && this.pageHeight > this.bodyHeight) {
      let by = this._rowSections.offsetOf(this._rowSections.count - 1);
      let y = Math.min(this.headerHeight + by, oldHeight);
      this.paintContent(0, y, width, height - y);
    } else if (height > oldHeight) {
      this.paintContent(0, oldHeight, width, height - oldHeight + 1);
    }

    // Paint the overlay.
    this._paintOverlay();
  }

  /**
   * A message hook invoked on a viewport `'scroll-request'` message.
   */
  private _onViewportScrollRequest(msg: Message): void {
    this._scrollTo(this._hScrollBar.value, this._vScrollBar.value);
  }

  /**
   * A message hook invoked on a viewport `'paint-request'` message.
   */
  private _onViewportPaintRequest(msg: Private.PaintRequest): void {
    // Bail early if the viewport is not visible.
    if (!this._viewport.isVisible) {
      return;
    }

    // Bail early if the viewport has zero area.
    if (this._viewportWidth === 0 || this._viewportHeight === 0) {
      return;
    }

    // Set up the paint limits.
    let xMin = 0;
    let yMin = 0;
    let xMax = this._viewportWidth - 1;
    let yMax = this._viewportHeight - 1;

    // Fetch the scroll position.
    let sx = this._scrollX;
    let sy = this._scrollY;

    // Fetch the header dimensions.
    let hw = this.headerWidth;
    let hh = this.headerHeight;

    // Fetch the section lists.
    let rs = this._rowSections;
    let cs = this._columnSections;
    let rhs = this._rowHeaderSections;
    let chs = this._columnHeaderSections;

    // Unpack the message data.
    let { region, r1, c1, r2, c2 } = msg;

    // Set up the paint variables.
    let x1: number;
    let y1: number;
    let x2: number;
    let y2: number;

    // Fill the paint variables based on the paint region.
    switch (region) {
      case 'all':
        x1 = xMin;
        y1 = yMin;
        x2 = xMax;
        y2 = yMax;
        break;
      case 'body':
        r1 = Math.max(0, Math.min(r1, rs.count));
        c1 = Math.max(0, Math.min(c1, cs.count));
        r2 = Math.max(0, Math.min(r2, rs.count));
        c2 = Math.max(0, Math.min(c2, cs.count));
        x1 = cs.offsetOf(c1) - sx + hw;
        y1 = rs.offsetOf(r1) - sy + hh;
        x2 = cs.extentOf(c2) - sx + hw;
        y2 = rs.extentOf(r2) - sy + hh;
        break;
      case 'row-header':
        r1 = Math.max(0, Math.min(r1, rs.count));
        c1 = Math.max(0, Math.min(c1, rhs.count));
        r2 = Math.max(0, Math.min(r2, rs.count));
        c2 = Math.max(0, Math.min(c2, rhs.count));
        x1 = rhs.offsetOf(c1);
        y1 = rs.offsetOf(r1) - sy + hh;
        x2 = rhs.extentOf(c2);
        y2 = rs.extentOf(r2) - sy + hh;
        break;
      case 'column-header':
        r1 = Math.max(0, Math.min(r1, chs.count));
        c1 = Math.max(0, Math.min(c1, cs.count));
        r2 = Math.max(0, Math.min(r2, chs.count));
        c2 = Math.max(0, Math.min(c2, cs.count));
        x1 = cs.offsetOf(c1) - sx + hw;
        y1 = chs.offsetOf(r1);
        x2 = cs.extentOf(c2) - sx + hw;
        y2 = chs.extentOf(r2);
        break;
      case 'corner-header':
        r1 = Math.max(0, Math.min(r1, chs.count));
        c1 = Math.max(0, Math.min(c1, rhs.count));
        r2 = Math.max(0, Math.min(r2, chs.count));
        c2 = Math.max(0, Math.min(c2, rhs.count));
        x1 = rhs.offsetOf(c1);
        y1 = chs.offsetOf(r1);
        x2 = rhs.extentOf(c2);
        y2 = chs.extentOf(r2);
        break;
      default:
        throw 'unreachable';
    }

    // Bail early if the dirty rect is outside the bounds.
    if (x2 < xMin || y2 < yMin || x1 > xMax || y1 > yMax) {
      return;
    }

    // Clamp the dirty rect to the paint bounds.
    x1 = Math.max(xMin, Math.min(x1, xMax));
    y1 = Math.max(yMin, Math.min(y1, yMax));
    x2 = Math.max(xMin, Math.min(x2, xMax));
    y2 = Math.max(yMin, Math.min(y2, yMax));

    // Paint the content of the dirty rect.
    this.paintContent(x1, y1, x2 - x1 + 1, y2 - y1 + 1);
  }

  /**
   * A message hook invoked on a viewport `'overlay-paint-request'` message.
   */
  private _onViewportOverlayPaintRequest(msg: Message): void {
    // Bail early if the viewport is not visible.
    if (!this._viewport.isVisible) {
      return;
    }

    // Bail early if the viewport has zero area.
    if (this._viewportWidth === 0 || this._viewportHeight === 0) {
      return;
    }

    // Paint the content of the overlay.
    this._paintOverlay();
  }

  /**
   * A message hook invoked on a viewport `'row-resize-request'` message.
   */
  private _onViewportRowResizeRequest(msg: Private.RowResizeRequest): void {
    if (msg.region === 'body') {
      this._resizeRow(msg.index, msg.size);
    } else {
      this._resizeColumnHeader(msg.index, msg.size);
    }
  }

  /**
   * A message hook invoked on a viewport `'column-resize-request'` message.
   */
  private _onViewportColumnResizeRequest(
    msg: Private.ColumnResizeRequest
  ): void {
    if (msg.region === 'body') {
      this._resizeColumn(msg.index, msg.size);
    } else {
      this._resizeRowHeader(msg.index, msg.size);
    }
  }

  /**
   * Handle the `thumbMoved` signal from a scroll bar.
   */
  private _onThumbMoved(sender: ScrollBar): void {
    MessageLoop.postMessage(this._viewport, Private.ScrollRequest);
  }

  /**
   * Handle the `pageRequested` signal from a scroll bar.
   */
  private _onPageRequested(
    sender: ScrollBar,
    dir: 'decrement' | 'increment'
  ): void {
    if (sender === this._vScrollBar) {
      this.scrollByPage(dir === 'decrement' ? 'up' : 'down');
    } else {
      this.scrollByPage(dir === 'decrement' ? 'left' : 'right');
    }
  }

  /**
   * Handle the `stepRequested` signal from a scroll bar.
   */
  private _onStepRequested(
    sender: ScrollBar,
    dir: 'decrement' | 'increment'
  ): void {
    if (sender === this._vScrollBar) {
      this.scrollByStep(dir === 'decrement' ? 'up' : 'down');
    } else {
      this.scrollByStep(dir === 'decrement' ? 'left' : 'right');
    }
  }

  /**
   * A signal handler for the data model `changed` signal.
   */
  private _onDataModelChanged(
    sender: DataModel,
    args: DataModel.ChangedArgs
  ): void {
    switch (args.type) {
      case 'rows-inserted':
        this._onRowsInserted(args);
        break;
      case 'columns-inserted':
        this._onColumnsInserted(args);
        break;
      case 'rows-removed':
        this._onRowsRemoved(args);
        break;
      case 'columns-removed':
        this._onColumnsRemoved(args);
        break;
      case 'rows-moved':
        this._onRowsMoved(args);
        break;
      case 'columns-moved':
        this._onColumnsMoved(args);
        break;
      case 'cells-changed':
        this._onCellsChanged(args);
        break;
      case 'model-reset':
        this._onModelReset(args);
        break;
      default:
        throw 'unreachable';
    }
  }

  /**
   * A signal handler for the selection model `changed` signal.
   */
  private _onSelectionsChanged(sender: SelectionModel): void {
    this.repaintOverlay();
  }

  /**
   * Handle rows being inserted in the data model.
   */
  private _onRowsInserted(args: DataModel.RowsChangedArgs): void {
    // Unpack the arg data.
    let { region, index, span } = args;

    // Bail early if there are no sections to insert.
    if (span <= 0) {
      return;
    }

    // Look up the relevant section list.
    let list: SectionList;
    if (region === 'body') {
      list = this._rowSections;
    } else {
      list = this._columnHeaderSections;
    }

    // Insert the span, maintaining the scroll position as needed.
    if (this._scrollY === this.maxScrollY && this.maxScrollY > 0) {
      list.insert(index, span);
      this._scrollY = this.maxScrollY;
    } else {
      list.insert(index, span);
    }

    // Sync the viewport.
    this._syncViewport();
  }

  /**
   * Handle columns being inserted into the data model.
   */
  private _onColumnsInserted(args: DataModel.ColumnsChangedArgs): void {
    // Unpack the arg data.
    let { region, index, span } = args;

    // Bail early if there are no sections to insert.
    if (span <= 0) {
      return;
    }

    // Look up the relevant section list.
    let list: SectionList;
    if (region === 'body') {
      list = this._columnSections;
    } else {
      list = this._rowHeaderSections;
    }

    // Insert the span, maintaining the scroll position as needed.
    if (this._scrollX === this.maxScrollX && this.maxScrollX > 0) {
      list.insert(index, span);
      this._scrollX = this.maxScrollX;
    } else {
      list.insert(index, span);
    }

    // Sync the viewport.
    this._syncViewport();
  }

  /**
   * Handle rows being removed from the data model.
   */
  private _onRowsRemoved(args: DataModel.RowsChangedArgs): void {
    // Unpack the arg data.
    let { region, index, span } = args;

    // Bail early if there are no sections to remove.
    if (span <= 0) {
      return;
    }

    // Look up the relevant section list.
    let list: SectionList;
    if (region === 'body') {
      list = this._rowSections;
    } else {
      list = this._columnHeaderSections;
    }

    // Bail if the index or is invalid
    if (index < 0 || index >= list.count) {
      return;
    }

    // Remove the span, maintaining the scroll position as needed.
    if (this._scrollY === this.maxScrollY && this.maxScrollY > 0) {
      list.remove(index, span);
      this._scrollY = this.maxScrollY;
    } else {
      list.remove(index, span);
    }

    // Sync the viewport.
    this._syncViewport();
  }

  /**
   * Handle columns being removed from the data model.
   */
  private _onColumnsRemoved(args: DataModel.ColumnsChangedArgs): void {
    // Unpack the arg data.
    let { region, index, span } = args;

    // Bail early if there are no sections to remove.
    if (span <= 0) {
      return;
    }

    // Look up the relevant section list.
    let list: SectionList;
    if (region === 'body') {
      list = this._columnSections;
    } else {
      list = this._rowHeaderSections;
    }

    // Bail if the index or is invalid
    if (index < 0 || index >= list.count) {
      return;
    }

    // Remove the span, maintaining the scroll position as needed.
    if (this._scrollX === this.maxScrollX && this.maxScrollX > 0) {
      list.remove(index, span);
      this._scrollX = this.maxScrollX;
    } else {
      list.remove(index, span);
    }

    // Sync the viewport.
    this._syncViewport();
  }

  /**
   * Handle rows moving in the data model.
   */
  private _onRowsMoved(args: DataModel.RowsMovedArgs): void {
    // Unpack the arg data.
    let { region, index, span, destination } = args;

    // Bail early if there are no sections to move.
    if (span <= 0) {
      return;
    }

    // Look up the relevant section list.
    let list: SectionList;
    if (region === 'body') {
      list = this._rowSections;
    } else {
      list = this._columnHeaderSections;
    }

    // Bail early if the index is out of range.
    if (index < 0 || index >= list.count) {
      return;
    }

    // Clamp the move span to the limit.
    span = Math.min(span, list.count - index);

    // Clamp the destination index to the limit.
    destination = Math.min(Math.max(0, destination), list.count - span);

    // Bail early if there is no effective move.
    if (index === destination) {
      return;
    }

    // Compute the first affected index.
    let r1 = Math.min(index, destination);

    // Compute the last affected index.
    let r2 = Math.max(index + span - 1, destination + span - 1);

    // Move the sections in the list.
    list.move(index, span, destination);

    // Schedule a repaint of the dirty cells.
    if (region === 'body') {
      this.repaintRegion('body', r1, 0, r2, Infinity);
      this.repaintRegion('row-header', r1, 0, r2, Infinity);
    } else {
      this.repaintRegion('column-header', r1, 0, r2, Infinity);
      this.repaintRegion('corner-header', r1, 0, r2, Infinity);
    }

    // Sync the viewport.
    this._syncViewport();
  }

  /**
   * Handle columns moving in the data model.
   */
  private _onColumnsMoved(args: DataModel.ColumnsMovedArgs): void {
    // Unpack the arg data.
    let { region, index, span, destination } = args;

    // Bail early if there are no sections to move.
    if (span <= 0) {
      return;
    }

    // Look up the relevant section list.
    let list: SectionList;
    if (region === 'body') {
      list = this._columnSections;
    } else {
      list = this._rowHeaderSections;
    }

    // Bail early if the index is out of range.
    if (index < 0 || index >= list.count) {
      return;
    }

    // Clamp the move span to the limit.
    span = Math.min(span, list.count - index);

    // Clamp the destination index to the limit.
    destination = Math.min(Math.max(0, destination), list.count - span);

    // Bail early if there is no effective move.
    if (index === destination) {
      return;
    }

    // Move the sections in the list.
    list.move(index, span, destination);

    // Compute the first affected index.
    let c1 = Math.min(index, destination);

    // Compute the last affected index.
    let c2 = Math.max(index + span - 1, destination + span - 1);

    // Schedule a repaint of the dirty cells.
    if (region === 'body') {
      this.repaintRegion('body', 0, c1, Infinity, c2);
      this.repaintRegion('column-header', 0, c1, Infinity, c2);
    } else {
      this.repaintRegion('row-header', 0, c1, Infinity, c2);
      this.repaintRegion('corner-header', 0, c1, Infinity, c2);
    }

    // Sync the viewport.
    this._syncViewport();
  }

  /**
   * Handle cells changing in the data model.
   */
  private _onCellsChanged(args: DataModel.CellsChangedArgs): void {
    // Unpack the arg data.
    let { region, row, column, rowSpan, columnSpan } = args;

    // Bail early if there are no cells to modify.
    if (rowSpan <= 0 && columnSpan <= 0) {
      return;
    }

    // Compute the changed cell bounds.
    let r1 = row;
    let c1 = column;
    let r2 = r1 + rowSpan - 1;
    let c2 = c1 + columnSpan - 1;

    // Schedule a repaint of the cell content.
    this.repaintRegion(region, r1, c1, r2, c2);
  }

  /**
   * Handle a full data model reset.
   */
  private _onModelReset(args: DataModel.ModelResetArgs): void {
    // Look up the various current section counts.
    let nr = this._rowSections.count;
    let nc = this._columnSections.count;
    let nrh = this._rowHeaderSections.count;
    let nch = this._columnHeaderSections.count;

    // Compute the delta count for each region.
    let dr = this._dataModel!.rowCount('body') - nr;
    let dc = this._dataModel!.columnCount('body') - nc;
    let drh = this._dataModel!.columnCount('row-header') - nrh;
    let dch = this._dataModel!.rowCount('column-header') - nch;

    // Update the row sections, if needed.
    if (dr > 0) {
      this._rowSections.insert(nr, dr);
    } else if (dr < 0) {
      this._rowSections.remove(nr + dr, -dr);
    }

    // Update the column sections, if needed.
    if (dc > 0) {
      this._columnSections.insert(nc, dc);
    } else if (dc < 0) {
      this._columnSections.remove(nc + dc, -dc);
    }

    // Update the row header sections, if needed.
    if (drh > 0) {
      this._rowHeaderSections.insert(nrh, drh);
    } else if (drh < 0) {
      this._rowHeaderSections.remove(nrh + drh, -drh);
    }

    // Update the column header sections, if needed.
    if (dch > 0) {
      this._columnHeaderSections.insert(nch, dch);
    } else if (dch < 0) {
      this._columnHeaderSections.remove(nch + dch, -dch);
    }

    // Sync the viewport.
    this._syncViewport();
  }

  /**
   * A signal handler for the renderer map `changed` signal.
   */
  private _onRenderersChanged(): void {
    this.repaintContent();
  }

  /**
   * Handle the `'keydown'` event for the data grid.
   */
  private _evtKeyDown(event: KeyboardEvent): void {
    if (this._mousedown) {
      event.preventDefault();
      event.stopPropagation();
    } else if (this._keyHandler) {
      this._keyHandler.onKeyDown(this, event);
    }
  }

  /**
   * Handle the `'mousedown'` event for the data grid.
   */
  private _evtMouseDown(event: MouseEvent): void {
    // Ignore everything except the left mouse button.
    if (event.button !== 0) {
      return;
    }

    // Activate the grid.
    this.activate();

    // Stop the event propagation.
    event.preventDefault();
    event.stopPropagation();

    // Add the extra document listeners.
    document.addEventListener('keydown', this, true);
    document.addEventListener('mouseup', this, true);
    document.addEventListener('mousedown', this, true);
    document.addEventListener('mousemove', this, true);
    document.addEventListener('contextmenu', this, true);

    // Flip the mousedown flag.
    this._mousedown = true;

    // Dispatch to the mouse handler.
    if (this._mouseHandler) {
      this._mouseHandler.onMouseDown(this, event);
    }
  }

  /**
   * Handle the `'mousemove'` event for the data grid.
   */
  private _evtMouseMove(event: MouseEvent): void {
    // Stop the event propagation if the mouse is down.
    if (this._mousedown) {
      event.preventDefault();
      event.stopPropagation();
    }

    // Bail if there is no mouse handler.
    if (!this._mouseHandler) {
      return;
    }

    // Dispatch to the mouse handler.
    if (this._mousedown) {
      this._mouseHandler.onMouseMove(this, event);
    } else {
      this._mouseHandler.onMouseHover(this, event);
    }
  }

  /**
   * Handle the `'mouseup'` event for the data grid.
   */
  private _evtMouseUp(event: MouseEvent): void {
    // Ignore everything except the left mouse button.
    if (event.button !== 0) {
      return;
    }

    // Stop the event propagation.
    event.preventDefault();
    event.stopPropagation();

    // Dispatch to the mouse handler.
    if (this._mouseHandler) {
      this._mouseHandler.onMouseUp(this, event);
    }

    // Release the mouse.
    this._releaseMouse();
  }

  /**
   * Handle the `'dblclick'` event for the data grid.
   */
  private _evtMouseDoubleClick(event: MouseEvent): void {
    // Ignore everything except the left mouse button.
    if (event.button !== 0) {
      return;
    }

    // Stop the event propagation.
    event.preventDefault();
    event.stopPropagation();

    // Dispatch to the mouse handler.
    if (this._mouseHandler) {
      this._mouseHandler.onMouseDoubleClick(this, event);
    }

    // Release the mouse.
    this._releaseMouse();
  }

  /**
   * Handle the `'mouseleave'` event for the data grid.
   */
  private _evtMouseLeave(event: MouseEvent): void {
    if (this._mousedown) {
      event.preventDefault();
      event.stopPropagation();
    } else if (this._mouseHandler) {
      this._mouseHandler.onMouseLeave(this, event);
    }
  }

  /**
   * Handle the `'contextmenu'` event for the data grid.
   */
  private _evtContextMenu(event: MouseEvent): void {
    if (this._mousedown) {
      event.preventDefault();
      event.stopPropagation();
    } else if (this._mouseHandler) {
      this._mouseHandler.onContextMenu(this, event);
    }
  }

  /**
   * Handle the `'wheel'` event for the data grid.
   */
  private _evtWheel(event: WheelEvent): void {
    // Ignore the event if `accel` is held.
    if (Platform.accelKey(event)) {
      return;
    }

    // Bail early if there is no mouse handler.
    if (!this._mouseHandler) {
      return;
    }

    // Dispatch to the mouse handler.
    this._mouseHandler.onWheel(this, event);
  }

  /**
   * Release the mouse grab.
   */
  private _releaseMouse(): void {
    // Clear the mousedown flag.
    this._mousedown = false;

    // Relase the mouse handler.
    if (this._mouseHandler) {
      this._mouseHandler.release();
    }

    // Remove the document listeners.
    document.removeEventListener('keydown', this, true);
    document.removeEventListener('mouseup', this, true);
    document.removeEventListener('mousedown', this, true);
    document.removeEventListener('mousemove', this, true);
    document.removeEventListener('contextmenu', this, true);
  }

  /**
   * Refresh the dpi ratio.
   */
  private _refreshDPI(): void {
    // Get the best integral value for the dpi ratio.
    let dpiRatio = Math.ceil(window.devicePixelRatio);

    // Bail early if the computed dpi ratio has not changed.
    if (this._dpiRatio === dpiRatio) {
      return;
    }

    // Update the internal dpi ratio.
    this._dpiRatio = dpiRatio;

    // Schedule a repaint of the content.
    this.repaintContent();

    // Schedule a repaint of the overlay.
    this.repaintOverlay();

    // Update the canvas size for the new dpi ratio.
    this._resizeCanvasIfNeeded(this._viewportWidth, this._viewportHeight);

    // Ensure the canvas style is scaled for the new ratio.
    this._canvas.style.width = `${this._canvas.width / this._dpiRatio}px`;
    this._canvas.style.height = `${this._canvas.height / this._dpiRatio}px`;

    // Ensure the overlay style is scaled for the new ratio.
    this._overlay.style.width = `${this._overlay.width / this._dpiRatio}px`;
    this._overlay.style.height = `${this._overlay.height / this._dpiRatio}px`;
  }

  /**
   * Resize a row section immediately.
   */
  private _resizeRow(index: number, size: number): void {
    // Look up the target section list.
    let list = this._rowSections;

    // Bail early if the index is out of range.
    if (index < 0 || index >= list.count) {
      return;
    }

    // Look up the old size of the section.
    let oldSize = list.sizeOf(index);

    // Normalize the new size of the section.
    let newSize = list.clampSize(size);

    // Bail early if the size does not change.
    if (oldSize === newSize) {
      return;
    }

    // Resize the section in the list.
    list.resize(index, newSize);

    // Get the current size of the viewport.
    let vw = this._viewportWidth;
    let vh = this._viewportHeight;

    // If there is nothing to paint, sync the scroll state.
    if (!this._viewport.isVisible || vw === 0 || vh === 0) {
      this._syncScrollState();
      return;
    }

    // Compute the size delta.
    let delta = newSize - oldSize;

    // Look up the column header height.
    let hh = this.headerHeight;

    // Compute the viewport offset of the section.
    let offset = list.offsetOf(index) + hh - this._scrollY;

    // Bail early if there is nothing to paint.
    if (hh >= vh || offset >= vh) {
      this._syncScrollState();
      return;
    }

    // Update the scroll position if the section is not visible.
    if (offset + oldSize <= hh) {
      this._scrollY += delta;
      this._syncScrollState();
      return;
    }

    // Compute the paint origin of the section.
    let pos = Math.max(hh, offset);

    // Paint from the section onward if it spans the viewport.
    if (offset + oldSize >= vh || offset + newSize >= vh) {
      this.paintContent(0, pos, vw, vh - pos);
      this._paintOverlay();
      this._syncScrollState();
      return;
    }

    // Compute the X blit dimensions.
    let sx = 0;
    let sw = vw;
    let dx = 0;

    // Compute the Y blit dimensions.
    let sy: number;
    let sh: number;
    let dy: number;
    if (offset + newSize <= hh) {
      sy = hh - delta;
      sh = vh - sy;
      dy = hh;
    } else {
      sy = offset + oldSize;
      sh = vh - sy;
      dy = sy + delta;
    }

    // Blit the valid content to the destination.
    this._blitContent(this._canvas, sx, sy, sw, sh, dx, dy);

    // Repaint the section if needed.
    if (newSize > 0 && offset + newSize > hh) {
      this.paintContent(0, pos, vw, offset + newSize - pos);
    }

    // Paint the trailing space as needed.
    if (this._stretchLastRow && this.pageHeight > this.bodyHeight) {
      let r = this._rowSections.count - 1;
      let y = hh + this._rowSections.offsetOf(r);
      this.paintContent(0, y, vw, vh - y);
    } else if (delta < 0) {
      this.paintContent(0, vh + delta, vw, -delta);
    }

    // Repaint merged cells that are intersected by the resized row
    // Otherwise it will be cut in two by the valid content, and drawn incorrectly
    for (const rgn of ['body', 'row-header'] as DataModel.CellRegion[]) {
      const cellGroups = CellGroup.getCellGroupsAtRow(
        this.dataModel!,
        rgn,
        index
      );

      let paintRgn = {
        region: rgn,
        xMin: 0,
        xMax: 0,
        yMin: 0,
        yMax: 0
      };

      let backgroundColor = undefined;

      switch (rgn) {
        case 'body':
          paintRgn.xMin = this.headerWidth;
          paintRgn.xMax = this.headerWidth + this.bodyWidth;
          paintRgn.yMin = this.headerHeight;
          paintRgn.yMax = this.headerHeight + this.bodyHeight;

          backgroundColor = this._style.backgroundColor;
          break;
        case 'row-header':
          paintRgn.xMin = 0;
          paintRgn.xMax = this.headerWidth;
          paintRgn.yMin = this.headerHeight;
          paintRgn.yMax = this.headerHeight + this.bodyHeight;

          backgroundColor = this._style.headerBackgroundColor;
          break;
      }

      this._paintMergedCells(cellGroups, paintRgn, backgroundColor);
    }

    // Paint the overlay.
    this._paintOverlay();

    // Sync the scroll state.
    this._syncScrollState();
  }

  /**
   * Resize a column section immediately.
   */
  private _resizeColumn(index: number, size: number | null): void {
    // Look up the target section list.
    let list = this._columnSections;

    // Bail early if the index is out of range.
    if (index < 0 || index >= list.count) {
      return;
    }

    const adjustedSize = size ?? this._getMaxWidthInColumn(index, 'body');

    if (!adjustedSize || adjustedSize == 0) {
      return;
    }

    // Look up the old size of the section.
    let oldSize = list.sizeOf(index);

    // Normalize the new size of the section.
    let newSize = list.clampSize(adjustedSize);

    // Bail early if the size does not change.
    if (oldSize === newSize) {
      return;
    }

    // Resize the section in the list.
    list.resize(index, newSize);

    // Get the current size of the viewport.
    let vw = this._viewportWidth;
    let vh = this._viewportHeight;

    // If there is nothing to paint, sync the scroll state.
    if (!this._viewport.isVisible || vw === 0 || vh === 0) {
      this._syncScrollState();
      return;
    }

    // Compute the size delta.
    let delta = newSize - oldSize;

    // Look up the row header width.
    let hw = this.headerWidth;

    // Compute the viewport offset of the section.
    let offset = list.offsetOf(index) + hw - this._scrollX;

    // Bail early if there is nothing to paint.
    if (hw >= vw || offset >= vw) {
      this._syncScrollState();
      return;
    }

    // Update the scroll position if the section is not visible.
    if (offset + oldSize <= hw) {
      this._scrollX += delta;
      this._syncScrollState();
      return;
    }

    // Compute the paint origin of the section.
    let pos = Math.max(hw, offset);

    // Paint from the section onward if it spans the viewport.
    if (offset + oldSize >= vw || offset + newSize >= vw) {
      this.paintContent(pos, 0, vw - pos, vh);
      this._paintOverlay();
      this._syncScrollState();
      return;
    }

    // Compute the Y blit dimensions.
    let sy = 0;
    let sh = vh;
    let dy = 0;

    // Compute the X blit dimensions.
    let sx: number;
    let sw: number;
    let dx: number;
    if (offset + newSize <= hw) {
      sx = hw - delta;
      sw = vw - sx;
      dx = hw;
    } else {
      sx = offset + oldSize;
      sw = vw - sx;
      dx = sx + delta;
    }

    // Blit the valid content to the destination.
    this._blitContent(this._canvas, sx, sy, sw, sh, dx, dy);

    // Repaint the section if needed.
    if (newSize > 0 && offset + newSize > hw) {
      this.paintContent(pos, 0, offset + newSize - pos, vh);
    }

    // Paint the trailing space as needed.
    if (this._stretchLastColumn && this.pageWidth > this.bodyWidth) {
      let c = this._columnSections.count - 1;
      let x = hw + this._columnSections.offsetOf(c);
      this.paintContent(x, 0, vw - x, vh);
    } else if (delta < 0) {
      this.paintContent(vw + delta, 0, -delta, vh);
    }

    // Repaint merged cells that are intersected by the resized column
    // Otherwise it will be cut in two by the valid content, and drawn incorrectly
    for (const rgn of ['body', 'column-header'] as DataModel.CellRegion[]) {
      const cellGroups = CellGroup.getCellGroupsAtColumn(
        this.dataModel!,
        rgn,
        index
      );

      let paintRgn = {
        region: rgn,
        xMin: 0,
        xMax: 0,
        yMin: 0,
        yMax: 0
      };

      let backgroundColor = undefined;

      switch (rgn) {
        case 'body':
          paintRgn.xMin = this.headerWidth;
          paintRgn.xMax = this.headerWidth + this.bodyWidth;
          paintRgn.yMin = this.headerHeight;
          paintRgn.yMax = this.headerHeight + this.bodyHeight;

          backgroundColor = this._style.backgroundColor;
          break;
        case 'column-header':
          paintRgn.xMin = this.headerWidth;
          paintRgn.xMax = this.headerWidth + this.bodyWidth;
          paintRgn.yMin = 0;
          paintRgn.yMax = this.headerHeight;

          backgroundColor = this._style.headerBackgroundColor;
          break;
      }

      this._paintMergedCells(cellGroups, paintRgn, backgroundColor);
    }

    // Paint the overlay.
    this._paintOverlay();

    // Sync the scroll state after painting.
    this._syncScrollState();
  }

  /**
   * Resize a row header section immediately.
   */
  private _resizeRowHeader(index: number, size: number | null): void {
    // Look up the target section list.
    let list = this._rowHeaderSections;

    // Bail early if the index is out of range.
    if (index < 0 || index >= list.count) {
      return;
    }

    const adjustedSize = size ?? this._getMaxWidthInColumn(index, 'row-header');

    if (!adjustedSize || adjustedSize == 0) {
      return;
    }

    // Look up the old size of the section.
    let oldSize = list.sizeOf(index);

    // Normalize the new size of the section.
    let newSize = list.clampSize(adjustedSize);

    // Bail early if the size does not change.
    if (oldSize === newSize) {
      return;
    }

    // Resize the section in the list.
    list.resize(index, newSize);

    // Get the current size of the viewport.
    let vw = this._viewportWidth;
    let vh = this._viewportHeight;

    // If there is nothing to paint, sync the scroll state.
    if (!this._viewport.isVisible || vw === 0 || vh === 0) {
      this._syncScrollState();
      return;
    }

    // Compute the size delta.
    let delta = newSize - oldSize;

    // Look up the offset of the section.
    let offset = list.offsetOf(index);

    // Bail early if the section is fully outside the viewport.
    if (offset >= vw) {
      this._syncScrollState();
      return;
    }

    // Paint the entire tail if the section spans the viewport.
    if (offset + oldSize >= vw || offset + newSize >= vw) {
      this.paintContent(offset, 0, vw - offset, vh);
      this._paintOverlay();
      this._syncScrollState();
      return;
    }

    // Compute the blit content dimensions.
    let sx = offset + oldSize;
    let sy = 0;
    let sw = vw - sx;
    let sh = vh;
    let dx = sx + delta;
    let dy = 0;

    // Blit the valid content to the destination.
    this._blitContent(this._canvas, sx, sy, sw, sh, dx, dy);

    // Repaint the header section if needed.
    if (newSize > 0) {
      this.paintContent(offset, 0, newSize, vh);
    }

    // Paint the trailing space as needed.
    if (this._stretchLastColumn && this.pageWidth > this.bodyWidth) {
      let c = this._columnSections.count - 1;
      let x = this.headerWidth + this._columnSections.offsetOf(c);
      this.paintContent(x, 0, vw - x, vh);
    } else if (delta < 0) {
      this.paintContent(vw + delta, 0, -delta, vh);
    }

    // Repaint merged cells that are intersected by the resized row
    // Otherwise it will be cut in two by the valid content, and drawn incorrectly
    for (const rgn of [
      'corner-header',
      'row-header'
    ] as DataModel.CellRegion[]) {
      const cellGroups = CellGroup.getCellGroupsAtColumn(
        this.dataModel!,
        rgn,
        index
      );

      let paintRgn = {
        region: rgn,
        xMin: 0,
        xMax: 0,
        yMin: 0,
        yMax: 0
      };

      switch (rgn) {
        case 'corner-header':
          paintRgn.xMin = 0;
          paintRgn.xMax = this.headerWidth;
          paintRgn.yMin = 0;
          paintRgn.yMax = this.headerHeight;
          break;
        case 'row-header':
          paintRgn.xMin = 0;
          paintRgn.xMax = this.headerWidth;
          paintRgn.yMin = this.headerHeight;
          paintRgn.yMax = this.headerHeight + this.bodyHeight;
          break;
      }

      this._paintMergedCells(
        cellGroups,
        paintRgn,
        this._style.headerBackgroundColor
      );
    }

    // Paint the overlay.
    this._paintOverlay();

    // Sync the scroll state after painting.
    this._syncScrollState();
  }

  /**
   * Resize a column header section immediately.
   */
  private _resizeColumnHeader(index: number, size: number): void {
    // Look up the target section list.
    let list = this._columnHeaderSections;

    // Bail early if the index is out of range.
    if (index < 0 || index >= list.count) {
      return;
    }

    // Look up the old size of the section.
    let oldSize = list.sizeOf(index);

    // Normalize the new size of the section.
    let newSize = list.clampSize(size);

    // Bail early if the size does not change.
    if (oldSize === newSize) {
      return;
    }

    // Resize the section in the list.
    list.resize(index, newSize);

    // Get the current size of the viewport.
    let vw = this._viewportWidth;
    let vh = this._viewportHeight;

    // If there is nothing to paint, sync the scroll state.
    if (!this._viewport.isVisible || vw === 0 || vh === 0) {
      this._syncScrollState();
      return;
    }

    // Paint the overlay.
    this._paintOverlay();

    // Compute the size delta.
    let delta = newSize - oldSize;

    // Look up the offset of the section.
    let offset = list.offsetOf(index);

    // Bail early if the section is fully outside the viewport.
    if (offset >= vh) {
      this._syncScrollState();
      return;
    }

    // Paint the entire tail if the section spans the viewport.
    if (offset + oldSize >= vh || offset + newSize >= vh) {
      this.paintContent(0, offset, vw, vh - offset);
      this._paintOverlay();
      this._syncScrollState();
      return;
    }

    // Compute the blit content dimensions.
    let sx = 0;
    let sy = offset + oldSize;
    let sw = vw;
    let sh = vh - sy;
    let dx = 0;
    let dy = sy + delta;

    // Blit the valid contents to the destination.
    this._blitContent(this._canvas, sx, sy, sw, sh, dx, dy);

    // Repaint the header section if needed.
    if (newSize > 0) {
      this.paintContent(0, offset, vw, newSize);
    }

    // Paint the trailing space as needed.
    if (this._stretchLastRow && this.pageHeight > this.bodyHeight) {
      let r = this._rowSections.count - 1;
      let y = this.headerHeight + this._rowSections.offsetOf(r);
      this.paintContent(0, y, vw, vh - y);
    } else if (delta < 0) {
      this.paintContent(0, vh + delta, vw, -delta);
    }

    // Repaint merged cells that are intersected by the resized row
    // Otherwise it will be cut in two by the valid content, and drawn incorrectly
    for (const rgn of [
      'corner-header',
      'column-header'
    ] as DataModel.CellRegion[]) {
      const cellGroups = CellGroup.getCellGroupsAtRow(
        this.dataModel!,
        rgn,
        index
      );

      let paintRgn = {
        region: rgn,
        xMin: 0,
        xMax: 0,
        yMin: 0,
        yMax: 0
      };

      switch (rgn) {
        case 'corner-header':
          paintRgn.xMin = 0;
          paintRgn.xMax = this.headerWidth;
          paintRgn.yMin = 0;
          paintRgn.yMax = this.headerHeight;
          break;
        case 'column-header':
          paintRgn.xMin = this.headerWidth;
          paintRgn.xMax = this.headerWidth + this.bodyWidth;
          paintRgn.yMin = 0;
          paintRgn.yMax = this.headerHeight;
          break;
      }

      this._paintMergedCells(
        cellGroups,
        paintRgn,
        this._style.headerBackgroundColor
      );
    }

    // Paint the overlay.
    this._paintOverlay();

    // Sync the scroll state after painting.
    this._syncScrollState();
  }

  /**
   * Scroll immediately to the specified offset position.
   */
  private _scrollTo(x: number, y: number): void {
    // Bail if no data model found.
    if (!this.dataModel) {
      return;
    }

    // Floor and clamp the position to the allowable range.
    x = Math.max(0, Math.min(Math.floor(x), this.maxScrollX));
    y = Math.max(0, Math.min(Math.floor(y), this.maxScrollY));

    // Synchronize the scroll bar values.
    this._hScrollBar.value = x;
    this._vScrollBar.value = y;

    // Compute the delta scroll amount.
    let dx = x - this._scrollX;
    let dy = y - this._scrollY;

    // Bail early if there is no effective scroll.
    if (dx === 0 && dy === 0) {
      return;
    }

    // Bail early if the viewport is not visible.
    if (!this._viewport.isVisible) {
      this._scrollX = x;
      this._scrollY = y;
      return;
    }

    // Get the current size of the viewport.
    let width = this._viewportWidth;
    let height = this._viewportHeight;

    // Bail early if the viewport is empty.
    if (width === 0 || height === 0) {
      this._scrollX = x;
      this._scrollY = y;
      return;
    }

    // Get the visible content origin.
    let contentX = this.headerWidth;
    let contentY = this.headerHeight;

    // Get the visible content dimensions.
    let contentWidth = width - contentX;
    let contentHeight = height - contentY;

    // Bail early if there is no content to draw.
    if (contentWidth <= 0 && contentHeight <= 0) {
      this._scrollX = x;
      this._scrollY = y;
      return;
    }

    // Compute the area which needs painting for the `dx` scroll.
    let dxArea = 0;
    if (dx !== 0 && contentWidth > 0) {
      if (Math.abs(dx) >= contentWidth) {
        dxArea = contentWidth * height;
      } else {
        dxArea = Math.abs(dx) * height;
      }
    }

    // Compute the area which needs painting for the `dy` scroll.
    let dyArea = 0;
    if (dy !== 0 && contentHeight > 0) {
      if (Math.abs(dy) >= contentHeight) {
        dyArea = width * contentHeight;
      } else {
        dyArea = width * Math.abs(dy);
      }
    }

    // If the area sum is larger than the total, paint everything.
    if (dxArea + dyArea >= width * height) {
      this._scrollX = x;
      this._scrollY = y;
      this.paintContent(0, 0, width, height);
      this._paintOverlay();
      return;
    }

    // Update the internal Y scroll position.
    this._scrollY = y;

    // Scroll the Y axis if needed. If the scroll distance exceeds
    // the visible height, paint everything. Otherwise, blit the
    // valid content and paint the dirty region.
    if (dy !== 0 && contentHeight > 0) {
      if (Math.abs(dy) >= contentHeight) {
        this.paintContent(0, contentY, width, contentHeight);
      } else {
        const x = 0;
        const y = dy < 0 ? contentY : contentY + dy;
        const w = width;
        const h = contentHeight - Math.abs(dy);
        this._blitContent(this._canvas, x, y, w, h, x, y - dy);
        this.paintContent(
          0,
          dy < 0 ? contentY : height - dy,
          width,
          Math.abs(dy)
        );

        // Repaint merged cells that are intersected by the scroll level
        // Otherwise it will be cut in two by the valid content, and drawn incorrectly
        for (const rgn of ['body', 'row-header'] as DataModel.CellRegion[]) {
          const cellgroups = CellGroup.getCellGroupsAtRegion(
            this.dataModel,
            rgn
          );

          let paintRgn = {
            region: rgn,
            xMin: 0,
            xMax: 0,
            yMin: 0,
            yMax: 0
          };

          let backgroundColor = undefined;

          switch (rgn) {
            case 'body':
              paintRgn.xMin = this.headerWidth;
              paintRgn.xMax = this.headerWidth + this.bodyWidth;
              paintRgn.yMin = this.headerHeight;
              paintRgn.yMax = this.headerHeight + this.bodyHeight;

              backgroundColor = this._style.backgroundColor;
              break;
            case 'row-header':
              paintRgn.xMin = 0;
              paintRgn.xMax = this.headerWidth;
              paintRgn.yMin = this.headerHeight;
              paintRgn.yMax = this.headerHeight + this.bodyHeight;

              backgroundColor = this._style.headerBackgroundColor;
              break;
          }

          this._paintMergedCells(cellgroups, paintRgn, backgroundColor);
        }
      }
    }

    // Update the internal X scroll position.
    this._scrollX = x;

    // Scroll the X axis if needed. If the scroll distance exceeds
    // the visible width, paint everything. Otherwise, blit the
    // valid content and paint the dirty region.
    if (dx !== 0 && contentWidth > 0) {
      if (Math.abs(dx) >= contentWidth) {
        this.paintContent(contentX, 0, contentWidth, height);
      } else {
        const x = dx < 0 ? contentX : contentX + dx;
        const y = 0;
        const w = contentWidth - Math.abs(dx);
        const h = height;
        this._blitContent(this._canvas, x, y, w, h, x - dx, y);
        this.paintContent(
          dx < 0 ? contentX : width - dx,
          0,
          Math.abs(dx),
          height
        );

        // Repaint merged cells that are intersected by the scroll level
        // Otherwise it will be cut in two by the valid content, and drawn incorrectly
        for (const rgn of ['body', 'column-header'] as DataModel.CellRegion[]) {
          const cellGroups = CellGroup.getCellGroupsAtRegion(
            this.dataModel,
            rgn
          );

          let paintRgn = {
            region: rgn,
            xMin: 0,
            xMax: 0,
            yMin: 0,
            yMax: 0
          };

          let backgroundColor = undefined;

          switch (rgn) {
            case 'body':
              paintRgn.xMin = this.headerWidth;
              paintRgn.xMax = this.headerWidth + this.bodyWidth;
              paintRgn.yMin = this.headerHeight;
              paintRgn.yMax = this.headerHeight + this.bodyHeight;

              backgroundColor = this._style.backgroundColor;
              break;
            case 'column-header':
              paintRgn.xMin = this.headerWidth;
              paintRgn.xMax = this.headerWidth + this.bodyWidth;
              paintRgn.yMin = 0;
              paintRgn.yMax = this.headerHeight;

              backgroundColor = this._style.headerBackgroundColor;
              break;
          }

          this._paintMergedCells(cellGroups, paintRgn, backgroundColor);
        }
      }
    }

    // Paint the overlay.
    this._paintOverlay();
  }
  /**
   * Blit content into the on-screen grid canvas.
   *
   * The rect should be expressed in viewport coordinates.
   *
   * This automatically accounts for the dpi ratio.
   */
  private _blitContent(
    source: HTMLCanvasElement,
    x: number,
    y: number,
    w: number,
    h: number,
    dx: number,
    dy: number
  ): void {
    // Scale the blit coordinates by the dpi ratio.
    x *= this._dpiRatio;
    y *= this._dpiRatio;
    w *= this._dpiRatio;
    h *= this._dpiRatio;
    dx *= this._dpiRatio;
    dy *= this._dpiRatio;

    // Save the current gc state.
    this._canvasGC.save();

    // Set the transform to the identity matrix.
    this._canvasGC.setTransform(1, 0, 0, 1, 0, 0);

    // Draw the specified content.
    this._canvasGC.drawImage(source, x, y, w, h, dx, dy, w, h);

    // Restore the gc state.
    this._canvasGC.restore();
  }

  /**
   * Paint the grid content for the given dirty rect.
   *
   * The rect should be expressed in valid viewport coordinates.
   *
   * This is the primary paint entry point. The individual `_draw*`
   * methods should not be invoked directly. This method dispatches
   * to the drawing methods in the correct order.
   */
  protected paintContent(rx: number, ry: number, rw: number, rh: number): void {
    // Scale the canvas and buffer GC for the dpi ratio.
    this._canvasGC.setTransform(this._dpiRatio, 0, 0, this._dpiRatio, 0, 0);
    this._bufferGC.setTransform(this._dpiRatio, 0, 0, this._dpiRatio, 0, 0);

    // Clear the dirty rect of all content.
    this._canvasGC.clearRect(rx, ry, rw, rh);

    // Draw the void region.
    this._drawVoidRegion(rx, ry, rw, rh);

    // Draw the body region.
    this._drawBodyRegion(rx, ry, rw, rh);

    // Draw the row header region.
    this._drawRowHeaderRegion(rx, ry, rw, rh);

    // Draw the column header region.
    this._drawColumnHeaderRegion(rx, ry, rw, rh);

    // Draw the corner header region.
    this.drawCornerHeaderRegion(rx, ry, rw, rh);
  }

  /**
   * Resizes body column headers so their text fits
   * without clipping or wrapping.
   * @param dataModel
   */
  private _fitBodyColumnHeaders(
    dataModel: DataModel,
    padding: number,
    numCols?: number
  ): void {
    // Get the body column count
    const bodyColumnCount =
      numCols === undefined ? dataModel.columnCount('body') : numCols;

    for (let i = 0; i < bodyColumnCount; i++) {
      /*
        if we're working with nested column headers,
        retrieve the nested levels and iterate on them.
      */
      const numRows = dataModel.rowCount('column-header');

      /*
        Calculate the maximum text width, across
        all nested rows under a given column number.
      */
      let maxWidth = 0;
      for (let j = 0; j < numRows; j++) {
        const config = DataGrid._getConfig(dataModel, j, i, 'column-header');
        const textWidth = this._getCellTextWidth(config);
        // Update the maximum width for that column.
        maxWidth = Math.max(maxWidth, textWidth);
      }

      /*
        Send a resize message with new width for the given column.
        Using a padding of 15 pixels to leave some room.
      */
      this.resizeColumn('body', i, maxWidth + padding);
    }
  }

  /**
   * Resizes row header columns so their text fits
   * without clipping or wrapping.
   * @param dataModel
   */
  private _fitRowColumnHeaders(
    dataModel: DataModel,
    padding: number,
    numCols?: number
  ): void {
    /*
      if we're working with nested row headers,
      retrieve the nested levels and iterate on them.
    */
    const rowColumnCount =
      numCols === undefined ? dataModel.columnCount('row-header') : numCols;

    for (let i = 0; i < rowColumnCount; i++) {
      const numCols = dataModel.rowCount('column-header');
      /*
        Calculate the maximum text width, across
        all nested columns under a given row index.
      */
      let maxWidth = 0;
      for (let j = 0; j < numCols; j++) {
        const config = DataGrid._getConfig(dataModel, j, i, 'corner-header');
        const textWidth = this._getCellTextWidth(config);
        maxWidth = Math.max(maxWidth, textWidth);
      }

      /*
        Send a resize message with new width for the given column.
        Using a padding of 15 pixels to leave some room.
      */
      this.resizeColumn('row-header', i, maxWidth + padding);
    }
  }

  /**
   * Paint the overlay content for the entire grid.
   *
   * This is the primary overlay paint entry point. The individual
   * `_draw*` methods should not be invoked directly. This method
   * dispatches to the drawing methods in the correct order.
   */
  private _paintOverlay(): void {
    // Scale the overlay GC for the dpi ratio.
    this._overlayGC.setTransform(this._dpiRatio, 0, 0, this._dpiRatio, 0, 0);

    // Clear the overlay of all content.
    this._overlayGC.clearRect(0, 0, this._overlay.width, this._overlay.height);

    // Draw the body selections.
    this._drawBodySelections();

    // Draw the row header selections.
    this._drawRowHeaderSelections();

    // Draw the column header selections.
    this._drawColumnHeaderSelections();

    // Draw the cursor.
    this._drawCursor();

    // Draw the shadows.
    this._drawShadows();
  }

  /**
   * Draw the void region for the dirty rect.
   */
  private _drawVoidRegion(
    rx: number,
    ry: number,
    rw: number,
    rh: number
  ): void {
    // Look up the void color.
    let color = this._style.voidColor;

    // Bail if there is no void color.
    if (!color) {
      return;
    }

    // Fill the dirty rect with the void color.
    this._canvasGC.fillStyle = color;
    this._canvasGC.fillRect(rx, ry, rw, rh);
  }

  /**
   * Draw the body region which intersects the dirty rect.
   */
  private _drawBodyRegion(
    rx: number,
    ry: number,
    rw: number,
    rh: number
  ): void {
    // Get the visible content dimensions.
    let contentW = this._columnSections.length - this._scrollX;
    let contentH = this._rowSections.length - this._scrollY;

    // Bail if there is no content to draw.
    if (contentW <= 0 || contentH <= 0) {
      return;
    }

    // Get the visible content origin.
    let contentX = this.headerWidth;
    let contentY = this.headerHeight;

    // Bail if the dirty rect does not intersect the content area.
    if (rx + rw <= contentX) {
      return;
    }
    if (ry + rh <= contentY) {
      return;
    }
    if (rx >= contentX + contentW) {
      return;
    }
    if (ry >= contentY + contentH) {
      return;
    }

    // Fetch the geometry.
    let bh = this.bodyHeight;
    let bw = this.bodyWidth;
    let ph = this.pageHeight;
    let pw = this.pageWidth;

    // Get the upper and lower bounds of the dirty content area.
    let x1 = Math.max(rx, contentX);
    let y1 = Math.max(ry, contentY);
    let x2 = Math.min(rx + rw - 1, contentX + contentW - 1);
    let y2 = Math.min(ry + rh - 1, contentY + contentH - 1);

    // Convert the dirty content bounds into cell bounds.
    let r1 = this._rowSections.indexOf(y1 - contentY + this._scrollY);
    let c1 = this._columnSections.indexOf(x1 - contentX + this._scrollX);
    let r2 = this._rowSections.indexOf(y2 - contentY + this._scrollY);
    let c2 = this._columnSections.indexOf(x2 - contentX + this._scrollX);

    // Fetch the max row and column.
    let maxRow = this._rowSections.count - 1;
    let maxColumn = this._columnSections.count - 1;

    // Handle a dirty content area larger than the cell count.
    if (r2 < 0) {
      r2 = maxRow;
    }
    if (c2 < 0) {
      c2 = maxColumn;
    }

    // Convert the cell bounds back to visible coordinates.
    let x = this._columnSections.offsetOf(c1) + contentX - this._scrollX;
    let y = this._rowSections.offsetOf(r1) + contentY - this._scrollY;

    // Set up the paint region size variables.
    let width = 0;
    let height = 0;

    // Allocate the section sizes arrays.
    let rowSizes = new Array<number>(r2 - r1 + 1);
    let columnSizes = new Array<number>(c2 - c1 + 1);

    // Get the row sizes for the region.
    for (let j = r1; j <= r2; ++j) {
      let size = this._rowSections.sizeOf(j);
      rowSizes[j - r1] = size;
      height += size;
    }

    // Get the column sizes for the region.
    for (let i = c1; i <= c2; ++i) {
      let size = this._columnSections.sizeOf(i);
      columnSizes[i - c1] = size;
      width += size;
    }

    // Adjust the geometry if the last row is streched.
    if (this._stretchLastRow && ph > bh && r2 === maxRow) {
      let dh = this.pageHeight - this.bodyHeight;
      rowSizes[rowSizes.length - 1] += dh;
      height += dh;
      y2 += dh;
    }

    // Adjust the geometry if the last column is streched.
    if (this._stretchLastColumn && pw > bw && c2 === maxColumn) {
      let dw = this.pageWidth - this.bodyWidth;
      columnSizes[columnSizes.length - 1] += dw;
      width += dw;
      x2 += dw;
    }

    // Create the paint region object.
    let rgn: Private.PaintRegion = {
      region: 'body',
      xMin: x1,
      yMin: y1,
      xMax: x2,
      yMax: y2,
      x,
      y,
      width,
      height,
      row: r1,
      column: c1,
      rowSizes,
      columnSizes
    };

    // Draw the background.
    this._drawBackground(rgn, this._style.backgroundColor);

    // Draw the row background.
    this._drawRowBackground(rgn, this._style.rowBackgroundColor);

    // Draw the column background.
    this._drawColumnBackground(rgn, this._style.columnBackgroundColor);

    // Draw the cell content for the paint region.
    this._drawCells(rgn);

    // Draw the horizontal grid lines.
    this._drawHorizontalGridLines(
      rgn,
      this._style.horizontalGridLineColor || this._style.gridLineColor
    );

    // Draw the vertical grid lines.
    this._drawVerticalGridLines(
      rgn,
      this._style.verticalGridLineColor || this._style.gridLineColor
    );

    // Get the cellgroups from the cell-region that intersects with the paint region
    const cellGroups = CellGroup.getCellGroupsAtRegion(
      this.dataModel!,
      rgn.region
    ).filter(group => {
      return this.cellGroupInteresectsRegion(group, rgn);
    });

    // Draw merged cells
    this._paintMergedCells(cellGroups, rgn, this._style.backgroundColor);
  }

  /**
   * Draw the row header region which intersects the dirty rect.
   */
  private _drawRowHeaderRegion(
    rx: number,
    ry: number,
    rw: number,
    rh: number
  ): void {
    // Get the visible content dimensions.
    let contentW = this.headerWidth;
    let contentH = this.bodyHeight - this._scrollY;

    // Bail if there is no content to draw.
    if (contentW <= 0 || contentH <= 0) {
      return;
    }

    // Get the visible content origin.
    let contentX = 0;
    let contentY = this.headerHeight;

    // Bail if the dirty rect does not intersect the content area.
    if (rx + rw <= contentX) {
      return;
    }
    if (ry + rh <= contentY) {
      return;
    }
    if (rx >= contentX + contentW) {
      return;
    }
    if (ry >= contentY + contentH) {
      return;
    }

    // Fetch the geometry.
    let bh = this.bodyHeight;
    let ph = this.pageHeight;

    // Get the upper and lower bounds of the dirty content area.
    let x1 = rx;
    let y1 = Math.max(ry, contentY);
    let x2 = Math.min(rx + rw - 1, contentX + contentW - 1);
    let y2 = Math.min(ry + rh - 1, contentY + contentH - 1);

    // Convert the dirty content bounds into cell bounds.
    let r1 = this._rowSections.indexOf(y1 - contentY + this._scrollY);
    let c1 = this._rowHeaderSections.indexOf(x1);
    let r2 = this._rowSections.indexOf(y2 - contentY + this._scrollY);
    let c2 = this._rowHeaderSections.indexOf(x2);

    // Fetch max row and column.
    let maxRow = this._rowSections.count - 1;
    let maxColumn = this._rowHeaderSections.count - 1;

    // Handle a dirty content area larger than the cell count.
    if (r2 < 0) {
      r2 = maxRow;
    }
    if (c2 < 0) {
      c2 = maxColumn;
    }

    // Convert the cell bounds back to visible coordinates.
    let x = this._rowHeaderSections.offsetOf(c1);
    let y = this._rowSections.offsetOf(r1) + contentY - this._scrollY;

    // Set up the paint region size variables.
    let width = 0;
    let height = 0;

    // Allocate the section sizes arrays.
    let rowSizes = new Array<number>(r2 - r1 + 1);
    let columnSizes = new Array<number>(c2 - c1 + 1);

    // Get the row sizes for the region.
    for (let j = r1; j <= r2; ++j) {
      let size = this._rowSections.sizeOf(j);
      rowSizes[j - r1] = size;
      height += size;
    }

    // Get the column sizes for the region.
    for (let i = c1; i <= c2; ++i) {
      let size = this._rowHeaderSections.sizeOf(i);
      columnSizes[i - c1] = size;
      width += size;
    }

    // Adjust the geometry if the last row is stretched.
    if (this._stretchLastRow && ph > bh && r2 === maxRow) {
      let dh = this.pageHeight - this.bodyHeight;
      rowSizes[rowSizes.length - 1] += dh;
      height += dh;
      y2 += dh;
    }

    // Create the paint region object.
    let rgn: Private.PaintRegion = {
      region: 'row-header',
      xMin: x1,
      yMin: y1,
      xMax: x2,
      yMax: y2,
      x,
      y,
      width,
      height,
      row: r1,
      column: c1,
      rowSizes,
      columnSizes
    };

    // Draw the background.
    this._drawBackground(rgn, this._style.headerBackgroundColor);

    // Draw the cell content for the paint region.
    this._drawCells(rgn);

    // Draw the horizontal grid lines.
    this._drawHorizontalGridLines(
      rgn,
      this._style.headerHorizontalGridLineColor ||
        this._style.headerGridLineColor
    );

    // Draw the vertical grid lines.
    this._drawVerticalGridLines(
      rgn,
      this._style.headerVerticalGridLineColor || this._style.headerGridLineColor
    );

    // Get the cellgroups from the cell-region that intersects with the paint region
    const cellGroups = CellGroup.getCellGroupsAtRegion(
      this.dataModel!,
      rgn.region
    ).filter(group => {
      return this.cellGroupInteresectsRegion(group, rgn);
    });

    // Draw merged cells
    this._paintMergedCells(cellGroups, rgn, this._style.headerBackgroundColor);
  }

  /**
   * Draw the column header region which intersects the dirty rect.
   */
  private _drawColumnHeaderRegion(
    rx: number,
    ry: number,
    rw: number,
    rh: number
  ): void {
    // Get the visible content dimensions.
    let contentW = this.bodyWidth - this._scrollX;
    let contentH = this.headerHeight;

    // Bail if there is no content to draw.
    if (contentW <= 0 || contentH <= 0) {
      return;
    }

    // Get the visible content origin.
    let contentX = this.headerWidth;
    let contentY = 0;

    // Bail if the dirty rect does not intersect the content area.
    if (rx + rw <= contentX) {
      return;
    }
    if (ry + rh <= contentY) {
      return;
    }
    if (rx >= contentX + contentW) {
      return;
    }
    if (ry >= contentY + contentH) {
      return;
    }

    // Fetch the geometry.
    let bw = this.bodyWidth;
    let pw = this.pageWidth;

    // Get the upper and lower bounds of the dirty content area.
    let x1 = Math.max(rx, contentX);
    let y1 = ry;
    let x2 = Math.min(rx + rw - 1, contentX + contentW - 1);
    let y2 = Math.min(ry + rh - 1, contentY + contentH - 1);

    // Convert the dirty content bounds into cell bounds.
    let r1 = this._columnHeaderSections.indexOf(y1);
    let c1 = this._columnSections.indexOf(x1 - contentX + this._scrollX);
    let r2 = this._columnHeaderSections.indexOf(y2);
    let c2 = this._columnSections.indexOf(x2 - contentX + this._scrollX);

    // Fetch the max row and column.
    let maxRow = this._columnHeaderSections.count - 1;
    let maxColumn = this._columnSections.count - 1;

    // Handle a dirty content area larger than the cell count.
    if (r2 < 0) {
      r2 = maxRow;
    }
    if (c2 < 0) {
      c2 = maxColumn;
    }

    // Convert the cell bounds back to visible coordinates.
    let x = this._columnSections.offsetOf(c1) + contentX - this._scrollX;
    let y = this._columnHeaderSections.offsetOf(r1);

    // Set up the paint region size variables.
    let width = 0;
    let height = 0;

    // Allocate the section sizes arrays.
    let rowSizes = new Array<number>(r2 - r1 + 1);
    let columnSizes = new Array<number>(c2 - c1 + 1);

    // Get the row sizes for the region.
    for (let j = r1; j <= r2; ++j) {
      let size = this._columnHeaderSections.sizeOf(j);
      rowSizes[j - r1] = size;
      height += size;
    }

    // Get the column sizes for the region.
    for (let i = c1; i <= c2; ++i) {
      let size = this._columnSections.sizeOf(i);
      columnSizes[i - c1] = size;
      width += size;
    }

    // Adjust the geometry if the last column is stretched.
    if (this._stretchLastColumn && pw > bw && c2 === maxColumn) {
      let dw = this.pageWidth - this.bodyWidth;
      columnSizes[columnSizes.length - 1] += dw;
      width += dw;
      x2 += dw;
    }

    // Create the paint region object.
    let rgn: Private.PaintRegion = {
      region: 'column-header',
      xMin: x1,
      yMin: y1,
      xMax: x2,
      yMax: y2,
      x,
      y,
      width,
      height,
      row: r1,
      column: c1,
      rowSizes,
      columnSizes
    };

    // Draw the background.
    this._drawBackground(rgn, this._style.headerBackgroundColor);

    // Draw the cell content for the paint region.
    this._drawCells(rgn);

    // Draw the horizontal grid lines.
    this._drawHorizontalGridLines(
      rgn,
      this._style.headerHorizontalGridLineColor ||
        this._style.headerGridLineColor
    );

    // Draw the vertical grid lines.
    this._drawVerticalGridLines(
      rgn,
      this._style.headerVerticalGridLineColor || this._style.headerGridLineColor
    );

    // Get the cellgroups from the cell-region that intersects with the paint region
    const cellGroups = CellGroup.getCellGroupsAtRegion(
      this.dataModel!,
      rgn.region
    ).filter(group => {
      return this.cellGroupInteresectsRegion(group, rgn);
    });

    // Draw merged cells
    this._paintMergedCells(cellGroups, rgn, this._style.headerBackgroundColor);
  }

  /**
   * Draw the corner header region which intersects the dirty rect.
   */
  protected drawCornerHeaderRegion(
    rx: number,
    ry: number,
    rw: number,
    rh: number
  ): void {
    // Get the visible content dimensions.
    let contentW = this.headerWidth;
    let contentH = this.headerHeight;

    // Bail if there is no content to draw.
    if (contentW <= 0 || contentH <= 0) {
      return;
    }

    // Get the visible content origin.
    let contentX = 0;
    let contentY = 0;

    // Bail if the dirty rect does not intersect the content area.
    if (rx + rw <= contentX) {
      return;
    }
    if (ry + rh <= contentY) {
      return;
    }
    if (rx >= contentX + contentW) {
      return;
    }
    if (ry >= contentY + contentH) {
      return;
    }

    // Get the upper and lower bounds of the dirty content area.
    let x1 = rx;
    let y1 = ry;
    let x2 = Math.min(rx + rw - 1, contentX + contentW - 1);
    let y2 = Math.min(ry + rh - 1, contentY + contentH - 1);

    // Convert the dirty content bounds into cell bounds.
    let r1 = this._columnHeaderSections.indexOf(y1);
    let c1 = this._rowHeaderSections.indexOf(x1);
    let r2 = this._columnHeaderSections.indexOf(y2);
    let c2 = this._rowHeaderSections.indexOf(x2);

    // Handle a dirty content area larger than the cell count.
    if (r2 < 0) {
      r2 = this._columnHeaderSections.count - 1;
    }
    if (c2 < 0) {
      c2 = this._rowHeaderSections.count - 1;
    }

    // Convert the cell bounds back to visible coordinates.
    let x = this._rowHeaderSections.offsetOf(c1);
    let y = this._columnHeaderSections.offsetOf(r1);

    // Set up the paint region size variables.
    let width = 0;
    let height = 0;

    // Allocate the section sizes arrays.
    let rowSizes = new Array<number>(r2 - r1 + 1);
    let columnSizes = new Array<number>(c2 - c1 + 1);

    // Get the row sizes for the region.
    for (let j = r1; j <= r2; ++j) {
      let size = this._columnHeaderSections.sizeOf(j);
      rowSizes[j - r1] = size;
      height += size;
    }

    // Get the column sizes for the region.
    for (let i = c1; i <= c2; ++i) {
      let size = this._rowHeaderSections.sizeOf(i);
      columnSizes[i - c1] = size;
      width += size;
    }

    // Create the paint region object.
    let rgn: Private.PaintRegion = {
      region: 'corner-header',
      xMin: x1,
      yMin: y1,
      xMax: x2,
      yMax: y2,
      x,
      y,
      width,
      height,
      row: r1,
      column: c1,
      rowSizes,
      columnSizes
    };

    // Draw the background.
    this._drawBackground(rgn, this._style.headerBackgroundColor);

    // Draw the cell content for the paint region.
    this._drawCells(rgn);

    // Draw the horizontal grid lines.
    this._drawHorizontalGridLines(
      rgn,
      this._style.headerHorizontalGridLineColor ||
        this._style.headerGridLineColor
    );

    // Draw the vertical grid lines.
    this._drawVerticalGridLines(
      rgn,
      this._style.headerVerticalGridLineColor || this._style.headerGridLineColor
    );

    // Get the cellgroups from the cell-region that intersects with the paint region
    const cellGroups = CellGroup.getCellGroupsAtRegion(
      this.dataModel!,
      rgn.region
    ).filter(group => {
      return this.cellGroupInteresectsRegion(group, rgn);
    });

    // Draw merged cells
    this._paintMergedCells(cellGroups, rgn, this._style.headerBackgroundColor);
  }

  /**
   * Draw the background for the given paint region.
   */
  private _drawBackground(
    rgn: Private.PaintRegion,
    color: string | undefined
  ): void {
    // Bail if there is no color to draw.
    if (!color) {
      return;
    }

    // Unpack the region.
    let { xMin, yMin, xMax, yMax } = rgn;

    // Fill the region with the specified color.
    this._canvasGC.fillStyle = color;
    this._canvasGC.fillRect(xMin, yMin, xMax - xMin + 1, yMax - yMin + 1);
  }

  /**
   * Draw the row background for the given paint region.
   */
  private _drawRowBackground(
    rgn: Private.PaintRegion,
    colorFn: ((i: number) => string) | undefined
  ): void {
    // Bail if there is no color function.
    if (!colorFn) {
      return;
    }

    // Compute the X bounds for the row.
    let x1 = Math.max(rgn.xMin, rgn.x);
    let x2 = Math.min(rgn.x + rgn.width - 1, rgn.xMax);

    // Draw the background for the rows in the region.
    for (let y = rgn.y, j = 0, n = rgn.rowSizes.length; j < n; ++j) {
      // Fetch the size of the row.
      let size = rgn.rowSizes[j];

      // Skip zero sized rows.
      if (size === 0) {
        continue;
      }

      // Get the background color for the row.
      let color = colorFn(rgn.row + j);

      // Fill the row with the background color if needed.
      if (color) {
        let y1 = Math.max(rgn.yMin, y);
        let y2 = Math.min(y + size - 1, rgn.yMax);
        this._canvasGC.fillStyle = color;
        this._canvasGC.fillRect(x1, y1, x2 - x1 + 1, y2 - y1 + 1);
      }

      // Increment the running Y coordinate.
      y += size;
    }
  }

  /**
   * Draw the column background for the given paint region.
   */
  private _drawColumnBackground(
    rgn: Private.PaintRegion,
    colorFn: ((i: number) => string) | undefined
  ): void {
    // Bail if there is no color function.
    if (!colorFn) {
      return;
    }

    // Compute the Y bounds for the column.
    let y1 = Math.max(rgn.yMin, rgn.y);
    let y2 = Math.min(rgn.y + rgn.height - 1, rgn.yMax);

    // Draw the background for the columns in the region.
    for (let x = rgn.x, i = 0, n = rgn.columnSizes.length; i < n; ++i) {
      // Fetch the size of the column.
      let size = rgn.columnSizes[i];

      // Skip zero sized columns.
      if (size === 0) {
        continue;
      }

      // Get the background color for the column.
      let color = colorFn(rgn.column + i);

      // Fill the column with the background color if needed.
      if (color) {
        let x1 = Math.max(rgn.xMin, x);
        let x2 = Math.min(x + size - 1, rgn.xMax);
        this._canvasGC.fillStyle = color;
        this._canvasGC.fillRect(x1, y1, x2 - x1 + 1, y2 - y1 + 1);
      }

      // Increment the running X coordinate.
      x += size;
    }
  }

  /**
   * Returns column size
   * @param region
   * @param index
   */
  private _getColumnSize(region: DataModel.CellRegion, index: number): number {
    if (region === 'corner-header') {
      return this._rowHeaderSections.sizeOf(index);
    }
    return this.columnSize(region as DataModel.ColumnRegion, index);
  }

  /**
   * Returns row size
   * @param region
   * @param index
   */
  private _getRowSize(region: DataModel.CellRegion, index: number): number {
    if (region === 'corner-header') {
      return this._columnHeaderSections.sizeOf(index);
    }
    return this.rowSize(region as DataModel.RowRegion, index);
  }

  /**
   * Draw the cells for the given paint region.
   */
  private _drawCells(rgn: Private.PaintRegion): void {
    // Bail if there is no data model.
    if (!this._dataModel) {
      return;
    }

    // Set up the cell config object for rendering.
    let config = {
      x: 0,
      y: 0,
      width: 0,
      height: 0,
      region: rgn.region,
      row: 0,
      column: 0,
      value: null as any,
      metadata: DataModel.emptyMetadata
    };

    let groupIndex = -1;

    // Save the buffer gc before wrapping.
    this._bufferGC.save();

    // Wrap the buffer gc for painting the cells.
    let gc = new GraphicsContext(this._bufferGC);

    let height = 0;

    // Loop over the columns in the region.
    for (let x = rgn.x, i = 0, n = rgn.columnSizes.length; i < n; ++i) {
      // Fetch the size of the column.
      let width = rgn.columnSizes[i];

      // Skip zero sized columns.
      if (width === 0) {
        continue;
      }

      // Compute the column index.
      let column = rgn.column + i;

      // Update the config for the current column.
      config.x = x;
      config.width = width;
      config.column = column;

      // Loop over the rows in the column.
      for (let y = rgn.y, j = 0, n = rgn.rowSizes.length; j < n; ++j) {
        // Fetch the size of the row.
        height = rgn.rowSizes[j];

        // Skip zero sized rows.
        if (height === 0) {
          continue;
        }

        // Compute the row index.
        let row = rgn.row + j;

        groupIndex = CellGroup.getGroupIndex(
          this.dataModel!,
          config.region,
          row,
          column
        );

        // For merged cell regions, don't do anything, we draw merged regions later.
        if (groupIndex !== -1) {
          y += height;
          continue;
        }

        // Clear the buffer rect for the cell.
        gc.clearRect(x, y, width, height);

        let value = DataGrid._getCellValue(
          this.dataModel as DataModel,
          rgn.region,
          row,
          column
        );
        let metadata = DataGrid._getCellMetadata(
          this.dataModel as DataModel,
          rgn.region,
          row,
          column
        );

        // Update the config for the current cell.
        config.y = y;
        config.height = height;
        config.width = width;
        config.row = row;
        config.value = value;
        config.metadata = metadata;

        // Get the renderer for the cell.
        let renderer = this._cellRenderers.get(config);

        // Save the GC state.
        gc.save();

        // Paint the cell into the off-screen buffer.
        try {
          if (renderer instanceof AsyncCellRenderer) {
            if (renderer.isReady(config)) {
              renderer.paint(gc, config);
            } else {
              renderer.paintPlaceholder(gc, config);
              renderer.load(config).then(() => {
                const r1 = row;
                const r2 = row + 1;

                const c1 = column;
                const c2 = column + 1;

                this.repaintRegion(rgn.region, r1, c1, r2, c2);
              });
            }
          } else {
            renderer.paint(gc, config);
          }
        } catch (err) {
          console.error(err);
        }

        // Restore the GC state.
        gc.restore();

        // Compute the actual X bounds for the cell.
        let x1 = Math.max(rgn.xMin, config.x);
        let x2 = Math.min(config.x + config.width - 1, rgn.xMax);

        // Compute the actual Y bounds for the cell.
        let y1 = Math.max(rgn.yMin, config.y);
        let y2 = Math.min(config.y + config.height - 1, rgn.yMax);

        this._blitContent(
          this._buffer,
          x1,
          y1,
          x2 - x1 + 1,
          y2 - y1 + 1,
          x1,
          y1
        );

        // Increment the running Y coordinate.
        y += height;
      }

      // Restore the GC state.
      gc.restore();

      // Increment the running X coordinate.
      x += width;
    }

    // Dispose of the wrapped gc.
    gc.dispose();

    // Restore the final buffer gc state.
    this._bufferGC.restore();
  }

  // TODO Move this in the utils file (but we need the PaintRegion typing)
  private cellGroupInteresectsRegion(
    group: CellGroup,
    rgn: Private.PaintRegion
  ) {
    const rgnR1 = rgn.row;
    const rgnR2 = rgn.row + rgn.rowSizes.length;

    const rgnC1 = rgn.column;
    const rgnC2 = rgn.column + rgn.columnSizes.length;

    const dx = Math.min(group.r2, rgnR2) - Math.max(group.r1, rgnR1);
    const dy = Math.min(group.c2, rgnC2) - Math.max(group.c1, rgnC1);

    return dx >= 0 && dy >= 0;
  }

  private static _getCellValue(
    dm: DataModel,
    region: DataModel.CellRegion,
    row: number,
    col: number
  ) {
    // Get the value for the cell.
    try {
      return dm.data(region, row, col);
    } catch (err) {
      console.error(err);
      return null;
    }
  }

  private static _getCellMetadata(
    dm: DataModel,
    region: DataModel.CellRegion,
    row: number,
    col: number
  ) {
    // Get the metadata for the cell.
    try {
      return dm.metadata(region, row, col);
    } catch (err) {
      console.error(err);
      return DataModel.emptyMetadata;
    }
  }

  /**
   * Paint group cells.
   */
  private _paintMergedCells(
    cellGroups: CellGroup[],
    rgn: Private.PixelRegion,
    backgroundColor: string | undefined
  ): void {
    // Bail if there is no data model.
    if (!this._dataModel) {
      return;
    }

    // Set up the cell config object for rendering.
    let config = {
      x: 0,
      y: 0,
      width: 0,
      height: 0,
      region: rgn.region,
      row: 0,
      column: 0,
      value: null as any,
      metadata: DataModel.emptyMetadata
    };

    if (backgroundColor) {
      this._canvasGC.fillStyle = backgroundColor;
    }
    // Set the line width for the grid lines.
    this._canvasGC.lineWidth = 1;

    // Save the buffer gc before wrapping.
    this._bufferGC.save();

    // Wrap the buffer gc for painting the cells.
    let gc = new GraphicsContext(this._bufferGC);

    for (const group of cellGroups) {
      let width = 0;
      for (let c = group.c1; c <= group.c2; c++) {
        width += this._getColumnSize(rgn.region, c);
      }

      let height = 0;
      for (let r = group.r1; r <= group.r2; r++) {
        height += this._getRowSize(rgn.region, r);
      }

      let value = DataGrid._getCellValue(
        this.dataModel as DataModel,
        rgn.region,
        group.r1,
        group.c1
      );
      let metadata = DataGrid._getCellMetadata(
        this.dataModel as DataModel,
        rgn.region,
        group.r1,
        group.c2
      );

      let x = 0;
      let y = 0;
      switch (rgn.region) {
        case 'body':
          x =
            this._columnSections.offsetOf(group.c1) +
            this.headerWidth -
            this._scrollX;
          y =
            this._rowSections.offsetOf(group.r1) +
            this.headerHeight -
            this._scrollY;
          break;
        case 'column-header':
          x =
            this._columnSections.offsetOf(group.c1) +
            this.headerWidth -
            this._scrollX;
          y = this._rowSections.offsetOf(group.r1);
          break;
        case 'row-header':
          x = this._columnSections.offsetOf(group.c1);
          y =
            this._rowSections.offsetOf(group.r1) +
            this.headerHeight -
            this._scrollY;
          break;
        case 'corner-header':
          x = this._columnSections.offsetOf(group.c1);
          y = this._rowSections.offsetOf(group.r1);
          break;
      }

      config.x = x;
      config.y = y;
      config.width = width;
      config.height = height;
      config.region = rgn.region;
      config.row = group.r1;
      config.column = group.c1;
      config.value = value;
      config.metadata = metadata;

      // Compute the actual X bounds for the cell.
      const x1 = Math.max(rgn.xMin, x);
      const x2 = Math.min(x + width - 2, rgn.xMax);

      // Compute the actual Y bounds for the cell.
      const y1 = Math.max(rgn.yMin, y);
      const y2 = Math.min(y + height - 2, rgn.yMax);

      if (x2 <= x1 || y2 <= y1) {
        continue;
      }

      // Draw the background.
      if (backgroundColor) {
        this._canvasGC.fillRect(x1, y1, x2 - x1 + 1, y2 - y1 + 1);
      }

      // Get the renderer for the cell.
      let renderer = this._cellRenderers.get(config);

      // Clear the buffer rect for the cell.
      gc.clearRect(config.x, config.y, width, height);

      // Save the GC state.
      gc.save();

      // Paint the cell into the off-screen buffer.
      try {
        if (renderer instanceof AsyncCellRenderer) {
          if (renderer.isReady(config)) {
            renderer.paint(gc, config);
          } else {
            renderer.paintPlaceholder(gc, config);

            const r1 = group.r1;
            const r2 = group.r2;

            const c1 = group.c1;
            const c2 = group.c2;

            renderer.load(config).then(() => {
              this.repaintRegion(rgn.region, r1, c1, r2, c2);
            });
          }
        } else {
          renderer.paint(gc, config);
        }
      } catch (err) {
        console.error(err);
      }

      // Restore the GC state.
      gc.restore();

      this._blitContent(this._buffer, x1, y1, x2 - x1 + 1, y2 - y1 + 1, x1, y1);
    }

    // Dispose of the wrapped gc.
    gc.dispose();

    // Restore the final buffer gc state.
    this._bufferGC.restore();
  }

  /**
   * Draw the horizontal grid lines for the given paint region.
   */
  private _drawHorizontalGridLines(
    rgn: Private.PaintRegion,
    color: string | undefined
  ): void {
    // Bail if there is no color to draw.
    if (!color) {
      return;
    }

    // Compute the X bounds for the horizontal lines.
    const x1 = Math.max(rgn.xMin, rgn.x);
    const x2 = Math.min(rgn.x + rgn.width, rgn.xMax + 1);

    // Begin the path for the grid lines.
    this._canvasGC.beginPath();

    // Set the line width for the grid lines.
    this._canvasGC.lineWidth = 1;

    // Fetch the geometry.
    const bh = this.bodyHeight;
    const ph = this.pageHeight;

    // Fetch the number of grid lines to be drawn.
    let n = rgn.rowSizes.length;

    // Adjust the count down if the last line shouldn't be drawn.
    if (this._stretchLastRow && ph > bh) {
      if (rgn.row + n === this._rowSections.count) {
        n -= 1;
      }
    }

    // Draw the horizontal grid lines.
    for (let y = rgn.y, j = 0; j < n; ++j) {
      // Fetch the size of the row.
      let size = rgn.rowSizes[j];

      // Skip zero sized rows.
      if (size === 0) {
        continue;
      }

      // Compute the Y position of the line.
      let pos = y + size - 1;

      // Draw the line if it's in range of the dirty rect.
      if (pos >= rgn.yMin && pos <= rgn.yMax) {
        this._canvasGC.moveTo(x1, pos + 0.5);
        this._canvasGC.lineTo(x2, pos + 0.5);
      }

      // Increment the running Y coordinate.
      y += size;
    }

    // Stroke the lines with the specified color.
    this._canvasGC.strokeStyle = color;
    this._canvasGC.stroke();
  }

  /**
   * Draw the vertical grid lines for the given paint region.
   */
  private _drawVerticalGridLines(
    rgn: Private.PaintRegion,
    color: string | undefined
  ): void {
    // Bail if there is no color to draw.
    if (!color) {
      return;
    }

    // Compute the Y bounds for the vertical lines.
    const y1 = Math.max(rgn.yMin, rgn.y);
    const y2 = Math.min(rgn.y + rgn.height, rgn.yMax + 1);

    // Begin the path for the grid lines
    this._canvasGC.beginPath();

    // Set the line width for the grid lines.
    this._canvasGC.lineWidth = 1;

    // Fetch the geometry.
    const bw = this.bodyWidth;
    const pw = this.pageWidth;

    // Fetch the number of grid lines to be drawn.
    let n = rgn.columnSizes.length;

    // Adjust the count down if the last line shouldn't be drawn.
    if (this._stretchLastColumn && pw > bw) {
      if (rgn.column + n === this._columnSections.count) {
        n -= 1;
      }
    }

    // Draw the vertical grid lines.
    for (let x = rgn.x, i = 0; i < n; ++i) {
      // Fetch the size of the column.
      let size = rgn.columnSizes[i];

      // Skip zero sized columns.
      if (size === 0) {
        continue;
      }

      // Compute the X position of the line.
      let pos = x + size - 1;

      // Draw the line if it's in range of the dirty rect.
      if (pos >= rgn.xMin && pos <= rgn.xMax) {
        this._canvasGC.moveTo(pos + 0.5, y1);
        this._canvasGC.lineTo(pos + 0.5, y2);
      }

      // Increment the running X coordinate.
      x += size;
    }

    // Stroke the lines with the specified color.
    this._canvasGC.strokeStyle = color;
    this._canvasGC.stroke();
  }

  /**
   * Draw the body selections for the data grid.
   */
  private _drawBodySelections(): void {
    // Fetch the selection model.
    let model = this._selectionModel;

    // Bail early if there are no selections.
    if (!model || model.isEmpty) {
      return;
    }

    // Fetch the selection colors.
    let fill = this._style.selectionFillColor;
    let stroke = this._style.selectionBorderColor;

    // Bail early if there is nothing to draw.
    if (!fill && !stroke) {
      return;
    }

    // Fetch the scroll geometry.
    let sx = this._scrollX;
    let sy = this._scrollY;

    // Get the first visible cell of the grid.
    let r1 = this._rowSections.indexOf(sy);
    let c1 = this._columnSections.indexOf(sx);

    // Bail early if there are no visible cells.
    if (r1 < 0 || c1 < 0) {
      return;
    }

    // Fetch the extra geometry.
    let bw = this.bodyWidth;
    let bh = this.bodyHeight;
    let pw = this.pageWidth;
    let ph = this.pageHeight;
    let hw = this.headerWidth;
    let hh = this.headerHeight;

    // Get the last visible cell of the grid.
    let r2 = this._rowSections.indexOf(sy + ph);
    let c2 = this._columnSections.indexOf(sx + pw);

    // Fetch the max row and column.
    let maxRow = this._rowSections.count - 1;
    let maxColumn = this._columnSections.count - 1;

    // Clamp the last cell if the void space is visible.
    r2 = r2 < 0 ? maxRow : r2;
    c2 = c2 < 0 ? maxColumn : c2;

    // Fetch the overlay gc.
    let gc = this._overlayGC;

    // Save the gc state.
    gc.save();

    // Set up the body clipping rect.
    gc.beginPath();
    gc.rect(hw, hh, pw, ph);
    gc.clip();

    // Set up the gc style.
    if (fill) {
      gc.fillStyle = fill;
    }
    if (stroke) {
      gc.strokeStyle = stroke;
      gc.lineWidth = 1;
    }

    // Iterate over the selections.
    for (let s of model.selections()) {
      // Skip the section if it's not visible.
      if (s.r1 < r1 && s.r2 < r1) {
        continue;
      }
      if (s.r1 > r2 && s.r2 > r2) {
        continue;
      }
      if (s.c1 < c1 && s.c2 < c1) {
        continue;
      }
      if (s.c1 > c2 && s.c2 > c2) {
        continue;
      }

      // Clamp the cell to the model bounds.
      let sr1 = Math.max(0, Math.min(s.r1, maxRow));
      let sc1 = Math.max(0, Math.min(s.c1, maxColumn));
      let sr2 = Math.max(0, Math.min(s.r2, maxRow));
      let sc2 = Math.max(0, Math.min(s.c2, maxColumn));

      // Swap index order if needed.
      let tmp: number;
      if (sr1 > sr2) {
        tmp = sr1;
        sr1 = sr2;
        sr2 = tmp;
      }
      if (sc1 > sc2) {
        tmp = sc1;
        sc1 = sc2;
        sc2 = tmp;
      }

      const joinedGroup = CellGroup.joinCellGroupWithMergedCellGroups(
        this.dataModel!,
        { r1: sr1, r2: sr2, c1: sc1, c2: sc2 },
        'body'
      );

      sr1 = joinedGroup.r1;
      sr2 = joinedGroup.r2;
      sc1 = joinedGroup.c1;
      sc2 = joinedGroup.c2;

      // Convert to pixel coordinates.
      let x1 = this._columnSections.offsetOf(sc1) - sx + hw;
      let y1 = this._rowSections.offsetOf(sr1) - sy + hh;
      let x2 = this._columnSections.extentOf(sc2) - sx + hw;
      let y2 = this._rowSections.extentOf(sr2) - sy + hh;

      // Adjust the trailing X coordinate for column stretch.
      if (this._stretchLastColumn && pw > bw && sc2 === maxColumn) {
        x2 = hw + pw - 1;
      }

      // Adjust the trailing Y coordinate for row stretch.
      if (this._stretchLastRow && ph > bh && sr2 === maxRow) {
        y2 = hh + ph - 1;
      }

      // Clamp the bounds to just outside of the clipping rect.
      x1 = Math.max(hw - 1, x1);
      y1 = Math.max(hh - 1, y1);
      x2 = Math.min(hw + pw + 1, x2);
      y2 = Math.min(hh + ph + 1, y2);

      // Skip zero sized ranges.
      if (x2 < x1 || y2 < y1) {
        continue;
      }

      // Fill the rect if needed.
      if (fill) {
        gc.fillRect(x1, y1, x2 - x1 + 1, y2 - y1 + 1);
      }

      // Stroke the rect if needed.
      if (stroke) {
        gc.strokeRect(x1 - 0.5, y1 - 0.5, x2 - x1 + 1, y2 - y1 + 1);
      }
    }

    // Restore the gc state.
    gc.restore();
  }

  /**
   * Draw the row header selections for the data grid.
   */
  private _drawRowHeaderSelections(): void {
    // Fetch the selection model.
    let model = this._selectionModel;

    // Bail early if there are no selections or if the selectionMode is the entire column.
    if (!model || model.isEmpty || model.selectionMode == 'column') {
      return;
    }

    // Bail early if the row headers are not visible.
    if (this.headerWidth === 0 || this.pageHeight === 0) {
      return;
    }

    // Fetch the selection colors.
    let fill = this._style.headerSelectionFillColor;
    let stroke = this._style.headerSelectionBorderColor;

    // Bail early if there is nothing to draw.
    if (!fill && !stroke) {
      return;
    }

    // Fetch common geometry.
    let sy = this._scrollY;
    let bh = this.bodyHeight;
    let ph = this.pageHeight;
    let hw = this.headerWidth;
    let hh = this.headerHeight;
    let rs = this._rowSections;

    // Fetch the overlay gc.
    let gc = this._overlayGC;

    // Save the gc state.
    gc.save();

    // Set up the header clipping rect.
    gc.beginPath();
    gc.rect(0, hh, hw, ph);
    gc.clip();

    // Set up the gc style.
    if (fill) {
      gc.fillStyle = fill;
    }
    if (stroke) {
      gc.strokeStyle = stroke;
      gc.lineWidth = 1;
    }

    // Fetch the max row.
    let maxRow = rs.count - 1;

    // Fetch the visible rows.
    let r1 = rs.indexOf(sy);
    let r2 = rs.indexOf(sy + ph - 1);
    r2 = r2 < 0 ? maxRow : r2;

    // Iterate over the visible rows.
    for (let j = r1; j <= r2; ++j) {
      // Skip rows which aren't selected.
      if (!model.isRowSelected(j)) {
        continue;
      }

      // Get the dimensions of the row.
      let y = rs.offsetOf(j) - sy + hh;
      let h = rs.sizeOf(j);

      // Adjust the height for row stretch.
      if (this._stretchLastRow && ph > bh && j === maxRow) {
        h = hh + ph - y;
      }

      // Skip zero sized rows.
      if (h === 0) {
        continue;
      }

      // Fill the rect if needed.
      if (fill) {
        gc.fillRect(0, y, hw, h);
      }

      // Draw the border if needed.
      if (stroke) {
        gc.beginPath();
        gc.moveTo(hw - 0.5, y - 1);
        gc.lineTo(hw - 0.5, y + h);
        gc.stroke();
      }
    }

    // Restore the gc state.
    gc.restore();
  }

  /**
   * Draw the column header selections for the data grid.
   */
  private _drawColumnHeaderSelections(): void {
    // Fetch the selection model.
    let model = this._selectionModel;

    // Bail early if there are no selections or if the selectionMode is the entire row
    if (!model || model.isEmpty || model.selectionMode == 'row') {
      return;
    }

    // Bail early if the column headers are not visible.
    if (this.headerHeight === 0 || this.pageWidth === 0) {
      return;
    }

    // Fetch the selection colors.
    let fill = this._style.headerSelectionFillColor;
    let stroke = this._style.headerSelectionBorderColor;

    // Bail early if there is nothing to draw.
    if (!fill && !stroke) {
      return;
    }

    // Fetch common geometry.
    let sx = this._scrollX;
    let bw = this.bodyWidth;
    let pw = this.pageWidth;
    let hw = this.headerWidth;
    let hh = this.headerHeight;
    let cs = this._columnSections;

    // Fetch the overlay gc.
    let gc = this._overlayGC;

    // Save the gc state.
    gc.save();

    // Set up the header clipping rect.
    gc.beginPath();
    gc.rect(hw, 0, pw, hh);
    gc.clip();

    // Set up the gc style.
    if (fill) {
      gc.fillStyle = fill;
    }
    if (stroke) {
      gc.strokeStyle = stroke;
      gc.lineWidth = 1;
    }

    // Fetch the max column.
    let maxCol = cs.count - 1;

    // Fetch the visible columns.
    let c1 = cs.indexOf(sx);
    let c2 = cs.indexOf(sx + pw - 1);
    c2 = c2 < 0 ? maxCol : c2;

    // Iterate over the visible columns.
    for (let i = c1; i <= c2; ++i) {
      // Skip columns which aren't selected.
      if (!model.isColumnSelected(i)) {
        continue;
      }

      // Get the dimensions of the column.
      let x = cs.offsetOf(i) - sx + hw;
      let w = cs.sizeOf(i);

      // Adjust the width for column stretch.
      if (this._stretchLastColumn && pw > bw && i === maxCol) {
        w = hw + pw - x;
      }

      // Skip zero sized columns.
      if (w === 0) {
        continue;
      }

      // Fill the rect if needed.
      if (fill) {
        gc.fillRect(x, 0, w, hh);
      }

      // Draw the border if needed.
      if (stroke) {
        gc.beginPath();
        gc.moveTo(x - 1, hh - 0.5);
        gc.lineTo(x + w, hh - 0.5);
        gc.stroke();
      }
    }

    // Restore the gc state.
    gc.restore();
  }

  /**
   * Draw the overlay cursor for the data grid.
   */
  private _drawCursor(): void {
    // Fetch the selection model.
    let model = this._selectionModel;

    // Bail early if there is no cursor.
    if (!model || model.isEmpty || model.selectionMode !== 'cell') {
      return;
    }

    // Extract the style information.
    let fill = this._style.cursorFillColor;
    let stroke = this._style.cursorBorderColor;

    // Bail early if there is nothing to draw.
    if (!fill && !stroke) {
      return;
    }

    // Fetch the cursor location.
    let startRow = model.cursorRow;
    let startColumn = model.cursorColumn;

    // Fetch the max row and column.
    let maxRow = this._rowSections.count - 1;
    let maxColumn = this._columnSections.count - 1;

    // Bail early if the cursor is out of bounds.
    if (startRow < 0 || startRow > maxRow) {
      return;
    }
    if (startColumn < 0 || startColumn > maxColumn) {
      return;
    }

    let endRow = startRow;
    let endColumn = startColumn;

    const joinedGroup = CellGroup.joinCellGroupWithMergedCellGroups(
      this.dataModel!,
      { r1: startRow, r2: endRow, c1: startColumn, c2: endColumn },
      'body'
    );

    startRow = joinedGroup.r1;
    endRow = joinedGroup.r2;
    startColumn = joinedGroup.c1;
    endColumn = joinedGroup.c2;

    // Fetch geometry.
    let sx = this._scrollX;
    let sy = this._scrollY;
    let bw = this.bodyWidth;
    let bh = this.bodyHeight;
    let pw = this.pageWidth;
    let ph = this.pageHeight;
    let hw = this.headerWidth;
    let hh = this.headerHeight;
    let vw = this._viewportWidth;
    let vh = this._viewportHeight;

    // Get the cursor bounds in viewport coordinates.
    let x1 = this._columnSections.offsetOf(startColumn) - sx + hw;
    let x2 = this._columnSections.extentOf(endColumn) - sx + hw;
    let y1 = this._rowSections.offsetOf(startRow) - sy + hh;
    let y2 = this._rowSections.extentOf(endRow) - sy + hh;

    // Adjust the trailing X coordinate for column stretch.
    if (this._stretchLastColumn && pw > bw && startColumn === maxColumn) {
      x2 = vw - 1;
    }

    // Adjust the trailing Y coordinate for row stretch.
    if (this._stretchLastRow && ph > bh && startRow === maxRow) {
      y2 = vh - 1;
    }

    // Skip zero sized cursors.
    if (x2 < x1 || y2 < y1) {
      return;
    }

    // Bail early if the cursor is off the screen.
    if (x1 - 1 >= vw || y1 - 1 >= vh || x2 + 1 < hw || y2 + 1 < hh) {
      return;
    }

    // Fetch the overlay gc.
    let gc = this._overlayGC;

    // Save the gc state.
    gc.save();

    // Set up the body clipping rect.
    gc.beginPath();
    gc.rect(hw, hh, pw, ph);
    gc.clip();

    // Clear any existing overlay content.
    gc.clearRect(x1, y1, x2 - x1 + 1, y2 - y1 + 1);

    // Fill the cursor rect if needed.
    if (fill) {
      // Set up the fill style.
      gc.fillStyle = fill;

      // Fill the cursor rect.
      gc.fillRect(x1, y1, x2 - x1 + 1, y2 - y1 + 1);
    }

    // Stroke the cursor border if needed.
    if (stroke) {
      // Set up the stroke style.
      gc.strokeStyle = stroke;
      gc.lineWidth = 2;

      // Stroke the cursor rect.
      gc.strokeRect(x1, y1, x2 - x1, y2 - y1);
    }

    // Restore the gc state.
    gc.restore();
  }

  /**
   * Draw the overlay shadows for the data grid.
   */
  private _drawShadows(): void {
    // Fetch the scroll shadow from the style.
    let shadow = this._style.scrollShadow;

    // Bail early if there is no shadow to draw.
    if (!shadow) {
      return;
    }

    // Fetch the scroll position.
    let sx = this._scrollX;
    let sy = this._scrollY;

    // Fetch maximum scroll position.
    let sxMax = this.maxScrollX;
    let syMax = this.maxScrollY;

    // Fetch the header width and height.
    let hw = this.headerWidth;
    let hh = this.headerHeight;

    // Fetch the page width and height.
    let pw = this.pageWidth;
    let ph = this.pageHeight;

    // Fetch the viewport width and height.
    let vw = this._viewportWidth;
    let vh = this._viewportHeight;

    // Fetch the body width and height.
    let bw = this.bodyWidth;
    let bh = this.bodyHeight;

    // Adjust the body size for row and column stretch.
    if (this._stretchLastRow && ph > bh) {
      bh = ph;
    }
    if (this._stretchLastColumn && pw > bw) {
      bw = pw;
    }

    // Fetch the gc object.
    let gc = this._overlayGC;

    // Save the gc state.
    gc.save();

    // Draw the column header shadow if needed.
    if (sy > 0) {
      // Set up the gradient coordinates.
      let x0 = 0;
      let y0 = hh;
      let x1 = 0;
      let y1 = y0 + shadow.size;

      // Create the gradient object.
      let grad = gc.createLinearGradient(x0, y0, x1, y1);

      // Set the gradient stops.
      grad.addColorStop(0, shadow.color1);
      grad.addColorStop(0.5, shadow.color2);
      grad.addColorStop(1, shadow.color3);

      // Set up the rect coordinates.
      let x = 0;
      let y = hh;
      let w = hw + Math.min(pw, bw - sx);
      let h = shadow.size;

      // Fill the shadow rect with the fill style.
      gc.fillStyle = grad;
      gc.fillRect(x, y, w, h);
    }

    // Draw the row header shadow if needed.
    if (sx > 0) {
      // Set up the gradient coordinates.
      let x0 = hw;
      let y0 = 0;
      let x1 = x0 + shadow.size;
      let y1 = 0;

      // Create the gradient object.
      let grad = gc.createLinearGradient(x0, y0, x1, y1);

      // Set the gradient stops.
      grad.addColorStop(0, shadow.color1);
      grad.addColorStop(0.5, shadow.color2);
      grad.addColorStop(1, shadow.color3);

      // Set up the rect coordinates.
      let x = hw;
      let y = 0;
      let w = shadow.size;
      let h = hh + Math.min(ph, bh - sy);

      // Fill the shadow rect with the fill style.
      gc.fillStyle = grad;
      gc.fillRect(x, y, w, h);
    }

    // Draw the column footer shadow if needed.
    if (sy < syMax) {
      // Set up the gradient coordinates.
      let x0 = 0;
      let y0 = vh;
      let x1 = 0;
      let y1 = vh - shadow.size;

      // Create the gradient object.
      let grad = gc.createLinearGradient(x0, y0, x1, y1);

      // Set the gradient stops.
      grad.addColorStop(0, shadow.color1);
      grad.addColorStop(0.5, shadow.color2);
      grad.addColorStop(1, shadow.color3);

      // Set up the rect coordinates.
      let x = 0;
      let y = vh - shadow.size;
      let w = hw + Math.min(pw, bw - sx);
      let h = shadow.size;

      // Fill the shadow rect with the fill style.
      gc.fillStyle = grad;
      gc.fillRect(x, y, w, h);
    }

    // Draw the row footer shadow if needed.
    if (sx < sxMax) {
      // Set up the gradient coordinates.
      let x0 = vw;
      let y0 = 0;
      let x1 = vw - shadow.size;
      let y1 = 0;

      // Create the gradient object.
      let grad = gc.createLinearGradient(x0, y0, x1, y1);

      // Set the gradient stops.
      grad.addColorStop(0, shadow.color1);
      grad.addColorStop(0.5, shadow.color2);
      grad.addColorStop(1, shadow.color3);

      // Set up the rect coordinates.
      let x = vw - shadow.size;
      let y = 0;
      let w = shadow.size;
      let h = hh + Math.min(ph, bh - sy);

      // Fill the shadow rect with the fill style.
      gc.fillStyle = grad;
      gc.fillRect(x, y, w, h);
    }

    // Restore the gc state.
    gc.restore();
  }

  private _viewport: Widget;
  private _vScrollBar: ScrollBar;
  private _hScrollBar: ScrollBar;
  private _scrollCorner: Widget;

  private _scrollX = 0;
  private _scrollY = 0;
  private _viewportWidth = 0;
  private _viewportHeight = 0;

  private _mousedown = false;
  private _keyHandler: DataGrid.IKeyHandler | null = null;
  private _mouseHandler: DataGrid.IMouseHandler | null = null;

  private _vScrollBarMinWidth = 0;
  private _hScrollBarMinHeight = 0;
  private _dpiRatio = Math.ceil(window.devicePixelRatio);

  private _canvas: HTMLCanvasElement;
  private _buffer: HTMLCanvasElement;
  private _overlay: HTMLCanvasElement;
  private _canvasGC: CanvasRenderingContext2D;
  private _bufferGC: CanvasRenderingContext2D;
  private _overlayGC: CanvasRenderingContext2D;

  private _rowSections: SectionList;
  private _columnSections: SectionList;
  private _rowHeaderSections: SectionList;
  private _columnHeaderSections: SectionList;

  private _dataModel: DataModel | null = null;
  private _selectionModel: SelectionModel | null = null;

  private _stretchLastRow: boolean;
  private _stretchLastColumn: boolean;

  private _style: DataGrid.Style;
  private _cellRenderers: RendererMap;
  private _copyConfig: DataGrid.CopyConfig;
  private _headerVisibility: DataGrid.HeaderVisibility;
  private _editorController: ICellEditorController | null;
  private _editingEnabled: boolean = false;
}

/**
 * The namespace for the `DataGrid` class statics.
 */
export namespace DataGrid {
  /**
   * An object which defines the style for a data grid.
   *
   * #### Notes
   * All style colors support the full CSS color syntax.
   */
  export type Style = {
    /**
     * The void color for the data grid.
     *
     * This is the base fill color for the entire data grid.
     */
    readonly voidColor?: string;

    /**
     * The background color for the body cells.
     *
     * This color is layered on top of the `voidColor`.
     */
    readonly backgroundColor?: string;

    /**
     * A function which returns the background color for a row.
     *
     * This color is layered on top of the `backgroundColor` and can
     * be used to implement "zebra striping" of the grid rows.
     */
    readonly rowBackgroundColor?: (index: number) => string;

    /**
     * A function which returns the background color for a column.
     *
     * This color is layered on top of the `backgroundColor` and can
     * be used to implement "zebra striping" of the grid columns.
     */
    readonly columnBackgroundColor?: (index: number) => string;

    /**
     * The color for the grid lines of the body cells.
     *
     * The grid lines are draw on top of the cell contents.
     */
    readonly gridLineColor?: string;

    /**
     * The color for the vertical grid lines of the body cells.
     *
     * This overrides the `gridLineColor` option.
     */
    readonly verticalGridLineColor?: string;

    /**
     * The color for the horizontal grid lines of the body cells.
     *
     * This overrides the `gridLineColor` option.
     */
    readonly horizontalGridLineColor?: string;

    /**
     * The background color for the header cells.
     *
     * This color is layered on top of the `voidColor`.
     */
    readonly headerBackgroundColor?: string;

    /**
     * The color for the grid lines of the header cells.
     *
     * The grid lines are draw on top of the cell contents.
     */
    readonly headerGridLineColor?: string;

    /**
     * The color for the vertical grid lines of the header cells.
     *
     * This overrides the `headerGridLineColor` option.
     */
    readonly headerVerticalGridLineColor?: string;

    /**
     * The color for the horizontal grid lines of the header cells.
     *
     * This overrides the `headerGridLineColor` option.
     */
    readonly headerHorizontalGridLineColor?: string;

    /**
     * The fill color for a selection.
     */
    readonly selectionFillColor?: string;

    /**
     * The border color for a selection.
     */
    readonly selectionBorderColor?: string;

    /**
     * The fill color for the cursor.
     */
    readonly cursorFillColor?: string;

    /**
     * The border color for the cursor.
     */
    readonly cursorBorderColor?: string;

    /**
     * The fill color for a header selection.
     */
    readonly headerSelectionFillColor?: string;

    /**
     * The border color for a header selection.
     */
    readonly headerSelectionBorderColor?: string;

    /**
     * The drop shadow effect when the grid is scrolled.
     */
    readonly scrollShadow?: {
      /**
       * The size of the shadow, in pixels.
       */
      readonly size: number;

      /**
       * The first color stop for the shadow.
       */
      readonly color1: string;

      /**
       * The second color stop for the shadow.
       */
      readonly color2: string;

      /**
       * The third color stop for the shadow.
       */
      readonly color3: string;
    };
  };

  /**
   * An object which defines the default sizes for a data grid.
   */
  export type DefaultSizes = {
    /**
     * The default height of a row.
     */
    readonly rowHeight: number;

    /**
     * The default width of a column.
     */
    readonly columnWidth: number;

    /**
     * The default width of a row header.
     */
    readonly rowHeaderWidth: number;

    /**
     * The default height of a column header.
     */
    readonly columnHeaderHeight: number;
  };

  /**
   * An object which defines the minimum sizes for a data grid.
   */
  export type MinimumSizes = {
    /**
     * The minimum height of a row.
     */
    readonly rowHeight: number;

    /**
     * The minimum width of a column.
     */
    readonly columnWidth: number;

    /**
     * The minimum width of a row header.
     */
    readonly rowHeaderWidth: number;

    /**
     * The minimum height of a column header.
     */
    readonly columnHeaderHeight: number;
  };

  /**
   * A type alias for the supported header visibility modes.
   */
  export type HeaderVisibility = 'all' | 'row' | 'column' | 'none';

  /**
   * A type alias for the supported column auto resize modes.
   */
  export type ColumnFitType = 'all' | 'row-header' | 'body';

  /**
   * A type alias for the arguments to a copy format function.
   */
  export type CopyFormatArgs = {
    /**
     * The cell region for the value.
     */
    region: DataModel.CellRegion;

    /**
     * The row index of the value.
     */
    row: number;

    /**
     * The column index of the value.
     */
    column: number;

    /**
     * The value for the cell.
     */
    value: any;

    /**
     * The metadata for the cell.
     */
    metadata: DataModel.Metadata;
  };

  /**
   * A type alias for a copy format function.
   */
  export type CopyFormatFunc = (args: CopyFormatArgs) => string;

  /**
   * A type alias for the data grid copy config.
   */
  export type CopyConfig = {
    /**
     * The separator to use between values.
     */
    readonly separator: string;

    /**
     * The headers to included in the copied output.
     */
    readonly headers: 'none' | 'row' | 'column' | 'all';

    /**
     * The function for formatting the data values.
     */
    readonly format: CopyFormatFunc;

    /**
     * The cell count threshold for a copy to be considered "large".
     */
    readonly warningThreshold: number;
  };

  /**
   * An options object for initializing a data grid.
   */
  export interface IOptions {
    /**
     * The style for the data grid.
     *
     * The default is `DataGrid.defaultStyle`.
     */
    style?: Style;

    /**
     * The default sizes for the data grid.
     *
     * The default is `DataGrid.defaultSizes`.
     */
    defaultSizes?: DefaultSizes;

    /**
     * The minimum sizes for the data grid.
     *
     * The default is `DataGrid.minimumSizes`.
     */
    minimumSizes?: MinimumSizes;

    /**
     * The header visibility for the data grid.
     *
     * The default is `'all'`.
     */
    headerVisibility?: HeaderVisibility;

    /**
     * The cell renderer map for the data grid.
     *
     * The default is an empty renderer map.
     */
    cellRenderers?: RendererMap;

    /**
     * The default cell renderer for the data grid.
     *
     * The default is a new `TextRenderer`.
     */
    defaultRenderer?: CellRenderer;

    /**
     * The copy configuration data for the grid.
     *
     * The default is `DataGrid.defaultCopyConfig`.
     */
    copyConfig?: CopyConfig;

    /**
     * Whether to stretch the last row of the grid.
     *
     * The default is `false`.
     */
    stretchLastRow?: boolean;

    /**
     * Whether to stretch the last column of the grid.
     *
     * The default is `false`.
     */
    stretchLastColumn?: boolean;
  }

  /**
   * An object which handles keydown events for the data grid.
   */
  export interface IKeyHandler extends IDisposable {
    /**
     * Handle the key down event for the data grid.
     *
     * @param grid - The data grid of interest.
     *
     * @param event - The keydown event of interest.
     *
     * #### Notes
     * This will not be called if the mouse button is pressed.
     */
    onKeyDown(grid: DataGrid, event: KeyboardEvent): void;
  }

  /**
   * An object which handles mouse events for the data grid.
   */
  export interface IMouseHandler extends IDisposable {
    /**
     * Release any resources acquired during a mouse press.
     *
     * #### Notes
     * This method is called when the mouse should be released
     * independent of a mouseup event, such as an early detach.
     */
    release(): void;

    /**
     * Handle the mouse hover event for the data grid.
     *
     * @param grid - The data grid of interest.
     *
     * @param event - The mouse hover event of interest.
     */
    onMouseHover(grid: DataGrid, event: MouseEvent): void;

    /**
     * Handle the mouse leave event for the data grid.
     *
     * @param grid - The data grid of interest.
     *
     * @param event - The mouse hover event of interest.
     */
    onMouseLeave(grid: DataGrid, event: MouseEvent): void;

    /**
     * Handle the mouse down event for the data grid.
     *
     * @param grid - The data grid of interest.
     *
     * @param event - The mouse down event of interest.
     */
    onMouseDown(grid: DataGrid, event: MouseEvent): void;

    /**
     * Handle the mouse move event for the data grid.
     *
     * @param grid - The data grid of interest.
     *
     * @param event - The mouse move event of interest.
     */
    onMouseMove(grid: DataGrid, event: MouseEvent): void;

    /**
     * Handle the mouse up event for the data grid.
     *
     * @param grid - The data grid of interest.
     *
     * @param event - The mouse up event of interest.
     */
    onMouseUp(grid: DataGrid, event: MouseEvent): void;

    /**
     * Handle the mouse double click event for the data grid.
     *
     * @param grid - The data grid of interest.
     *
     * @param event - The mouse double click event of interest.
     */
    onMouseDoubleClick(grid: DataGrid, event: MouseEvent): void;

    /**
     * Handle the context menu event for the data grid.
     *
     * @param grid - The data grid of interest.
     *
     * @param event - The context menu event of interest.
     */
    onContextMenu(grid: DataGrid, event: MouseEvent): void;

    /**
     * Handle the wheel event for the data grid.
     *
     * @param grid - The data grid of interest.
     *
     * @param event - The wheel event of interest.
     */
    onWheel(grid: DataGrid, event: WheelEvent): void;
  }

  /**
   * An object which holds the result of a grid hit test.
   */
  export type HitTestResult = {
    /**
     * The region of the data grid that was hit.
     */
    readonly region: DataModel.CellRegion | 'void';

    /**
     * The row index of the cell that was hit.
     *
     * This is `-1` for the `void` region.
     */
    readonly row: number;

    /**
     * The column index of the cell that was hit.
     *
     * This is `-1` for the `void` region.
     */
    readonly column: number;

    /**
     * The X coordinate of the mouse in cell coordinates.
     *
     * This is `-1` for the `void` region.
     */
    readonly x: number;

    /**
     * The Y coordinate of the mouse in cell coordinates.
     *
     * This is `-1` for the `void` region.
     */
    readonly y: number;

    /**
     * The width of the cell.
     *
     * This is `-1` for the `void` region.
     */
    readonly width: number;

    /**
     * The height of the cell.
     *
     * This is `-1` for the `void` region.
     */
    readonly height: number;
  };

  /**
   * A generic format function for the copy handler.
   *
   * @param args - The format args for the function.
   *
   * @returns The string representation of the value.
   *
   * #### Notes
   * This function uses `String()` to coerce a value to a string.
   */
  export function copyFormatGeneric(args: CopyFormatArgs): string {
    if (args.value === null || args.value === undefined) {
      return '';
    }
    return String(args.value);
  }

  /**
   * The default theme for a data grid.
   */
  export const defaultStyle: Style = {
    voidColor: '#F3F3F3',
    backgroundColor: '#FFFFFF',
    gridLineColor: 'rgba(20, 20, 20, 0.15)',
    headerBackgroundColor: '#F3F3F3',
    headerGridLineColor: 'rgba(20, 20, 20, 0.25)',
    selectionFillColor: 'rgba(49, 119, 229, 0.2)',
    selectionBorderColor: 'rgba(0, 107, 247, 1.0)',
    cursorBorderColor: 'rgba(0, 107, 247, 1.0)',
    headerSelectionFillColor: 'rgba(20, 20, 20, 0.1)',
    headerSelectionBorderColor: 'rgba(0, 107, 247, 1.0)',
    scrollShadow: {
      size: 10,
      color1: 'rgba(0, 0, 0, 0.20)',
      color2: 'rgba(0, 0, 0, 0.05)',
      color3: 'rgba(0, 0, 0, 0.00)'
    }
  };

  /**
   * The default sizes for a data grid.
   */
  export const defaultSizes: DefaultSizes = {
    rowHeight: 20,
    columnWidth: 64,
    rowHeaderWidth: 64,
    columnHeaderHeight: 20
  };

  /**
   * The default minimum sizes for a data grid.
   */
  export const minimumSizes: MinimumSizes = {
    rowHeight: 20,
    columnWidth: 10,
    rowHeaderWidth: 10,
    columnHeaderHeight: 20
  };

  /**
   * The default copy config for a data grid.
   */
  export const defaultCopyConfig: CopyConfig = {
    separator: '\t',
    format: copyFormatGeneric,
    headers: 'none',
    warningThreshold: 1e6
  };

  /**
   * A body region defined by the cells' boundaries using row/column coordinates.
   */
  export interface IBodyRegion {
    /**
     * First row of the region
     */
    firstRow: number;

    /**
     * Last row of the region
     */
    lastRow: number;

    /**
     * First column of the region
     */
    firstColumn: number;

    /**
     * Last column of the region
     */
    lastColumn: number;
  }
}

/**
 * The namespace for the module implementation details.
 */
namespace Private {
  /**
   * A singleton `scroll-request` conflatable message.
   */
  export const ScrollRequest = new ConflatableMessage('scroll-request');

  /**
   * A singleton `overlay-paint-request` conflatable message.
   */
  export const OverlayPaintRequest = new ConflatableMessage(
    'overlay-paint-request'
  );

  /**
   * Create a new zero-sized canvas element.
   */
  export function createCanvas(): HTMLCanvasElement {
    let canvas = document.createElement('canvas');
    canvas.width = 0;
    canvas.height = 0;
    return canvas;
  }

  /**
   * Checks whether a given regions has merged cells in it.
   * @param dataModel grid's data model.
   * @param region the paint region to be checked.
   * @returns boolean.
   */
  export function regionHasMergedCells(
    dataModel: DataModel,
    region: DataModel.CellRegion
  ): boolean {
    const regionGroups = CellGroup.getCellGroupsAtRegion(dataModel!, region);
    return regionGroups.length > 0;
  }

  /**
   * An object which represents a canvas region in pixels.
   */
  export type PixelRegion = {
    /**
     * The min X coordinate the of the dirty viewport rect.
     *
     * #### Notes
     * The data grid must not draw outside of this boundary.
     */
    xMin: number;

    /**
     * The min Y coordinate the of the dirty viewport rect.
     *
     * #### Notes
     * The data grid must not draw outside of this boundary.
     */
    yMin: number;

    /**
     * The max X coordinate the of the dirty viewport rect.
     *
     * #### Notes
     * The data grid must not draw outside of this boundary.
     */
    xMax: number;

    /**
     * The max Y coordinate the of the dirty viewport rect.
     *
     * #### Notes
     * The data grid must not draw outside of this boundary.
     */
    yMax: number;

    /**
     * The cell region.
     */
    region: DataModel.CellRegion;
  };

  /**
   * An object which represents a region to be painted.
   */
  export type PaintRegion = PixelRegion & {
    /**
     * The X coordinate the of the region, in viewport coordinates.
     *
     * #### Notes
     * This is aligned to the first cell boundary.
     */
    x: number;

    /**
     * The Y coordinate the of the region, in viewport coordinates.
     *
     * #### Notes
     * This is aligned to the first cell boundary.
     */
    y: number;

    /**
     * The total width of the region.
     *
     * #### Notes
     * This is aligned to the cell boundaries.
     */
    width: number;

    /**
     * The total height of the region.
     *
     * #### Notes
     * This is aligned to the cell boundaries.
     */
    height: number;

    /**
     * The row index of the first cell in the region.
     */
    row: number;

    /**
     * The column index of the first cell in the region.
     */
    column: number;

    /**
     * The row sizes for the rows in the region.
     */
    rowSizes: number[];

    /**
     * The column sizes for the columns in the region.
     */
    columnSizes: number[];
  };

  /**
   * A conflatable message which merges dirty paint regions.
   */
  export class PaintRequest extends ConflatableMessage {
    /**
     * Construct a new paint request messages.
     *
     * @param region - The cell region for the paint.
     *
     * @param r1 - The top-left row of the dirty region.
     *
     * @param c1 - The top-left column of the dirty region.
     *
     * @param r2 - The bottom-right row of the dirty region.
     *
     * @param c2 - The bottom-right column of the dirty region.
     */
    constructor(
      region: DataModel.CellRegion | 'all',
      r1: number,
      c1: number,
      r2: number,
      c2: number
    ) {
      super('paint-request');
      this._region = region;
      this._r1 = r1;
      this._c1 = c1;
      this._r2 = r2;
      this._c2 = c2;
    }

    /**
     * The cell region for the paint.
     */
    get region(): DataModel.CellRegion | 'all' {
      return this._region;
    }

    /**
     * The top-left row of the dirty region.
     */
    get r1(): number {
      return this._r1;
    }

    /**
     * The top-left column of the dirty region.
     */
    get c1(): number {
      return this._c1;
    }

    /**
     * The bottom-right row of the dirty region.
     */
    get r2(): number {
      return this._r2;
    }

    /**
     * The bottom-right column of the dirty region.
     */
    get c2(): number {
      return this._c2;
    }

    /**
     * Conflate this message with another paint request.
     */
    conflate(other: PaintRequest): boolean {
      // Bail early if the request is already painting everything.
      if (this._region === 'all') {
        return true;
      }

      // Any region can conflate with the `'all'` region.
      if (other._region === 'all') {
        this._region = 'all';
        return true;
      }

      // Otherwise, do not conflate with a different region.
      if (this._region !== other._region) {
        return false;
      }

      // Conflate the region to the total boundary.
      this._r1 = Math.min(this._r1, other._r1);
      this._c1 = Math.min(this._c1, other._c1);
      this._r2 = Math.max(this._r2, other._r2);
      this._c2 = Math.max(this._c2, other._c2);
      return true;
    }

    private _region: DataModel.CellRegion | 'all';
    private _r1: number;
    private _c1: number;
    private _r2: number;
    private _c2: number;
  }

  /**
   * A conflatable message for resizing rows.
   */
  export class RowResizeRequest extends ConflatableMessage {
    /**
     * Construct a new row resize request.
     *
     * @param region - The row region which holds the section.
     *
     * @param index - The index of row in the region.
     *
     * @param size - The target size of the section.
     */
    constructor(region: DataModel.RowRegion, index: number, size: number) {
      super('row-resize-request');
      this._region = region;
      this._index = index;
      this._size = size;
    }

    /**
     * The row region which holds the section.
     */
    get region(): DataModel.RowRegion {
      return this._region;
    }

    /**
     * The index of the row in the region.
     */
    get index(): number {
      return this._index;
    }

    /**
     * The target size of the section.
     */
    get size(): number {
      return this._size;
    }

    /**
     * Conflate this message with another row resize request.
     */
    conflate(other: RowResizeRequest): boolean {
      if (this._region !== other._region || this._index !== other._index) {
        return false;
      }
      this._size = other._size;
      return true;
    }

    private _region: DataModel.RowRegion;
    private _index: number;
    private _size: number;
  }

  /**
   * A conflatable message for resizing columns.
   */
  export class ColumnResizeRequest extends ConflatableMessage {
    /**
     * Construct a new column resize request.
     *
     * @param region - The column region which holds the section.
     *
     * @param index - The index of column in the region.
     *
     * @param size - The target size of the section.
     *               If null, then infer the size to fit.
     */
    constructor(
      region: DataModel.ColumnRegion,
      index: number,
      size: number | null
    ) {
      super('column-resize-request');
      this._region = region;
      this._index = index;
      this._size = size;
    }

    /**
     * The column region which holds the section.
     */
    get region(): DataModel.ColumnRegion {
      return this._region;
    }

    /**
     * The index of the column in the region.
     */
    get index(): number {
      return this._index;
    }

    /**
     * The target size of the section.
     */
    get size(): number | null {
      return this._size;
    }

    /**
     * Conflate this message with another column resize request.
     */
    conflate(other: ColumnResizeRequest): boolean {
      if (this._region !== other._region || this._index !== other._index) {
        return false;
      }
      this._size = other._size;
      return true;
    }

    private _region: DataModel.ColumnRegion;
    private _index: number;
    private _size: number | null;
  }
}
