hitTest method

SSpreadsheetHitResult hitTest(
  1. double viewportLocalX,
  2. double viewportLocalY,
  3. double viewportWidth,
  4. double viewportHeight,
)

Maps a viewport-local position to a spreadsheet grid cell.

viewportLocalX and viewportLocalY are coordinates relative to the top-left corner of this spreadsheet widget's bounding box. viewportWidth and viewportHeight are the widget's visible dimensions.

All four are measured in the widget's own box — the box it paints into — so a caller can keep converting pointers against the sheet's RenderBox without knowing the zoom: at a factor z the sheet's logical content is laid out at 1/z of that box, so these are mapped back into logical space before any row/column arithmetic begins, and the cell returned is the one under the pointer.

Returns an SSpreadsheetHitResult with grid cell indices, progress within the hit cell, and visibility context for edge-triggered auto-scroll.

Implementation

SSpreadsheetHitResult hitTest(
  double viewportLocalX,
  double viewportLocalY,
  double viewportWidth,
  double viewportHeight,
) {
  final totalRows = widget.rowCount;
  final totalColumns = widget.columnCount;
  final headerH = widget.showColumnHeader ? widget.headerHeight : 0.0;
  final rowHeaderW = widget.rowHeaderWidth;

  // Undo the zoom, so everything below stays in the logical space the row
  // heights, column widths and scroll offsets are expressed in.
  final zoomFactor = _zoomController.zoom;
  final localX =
      zoomFactor == 1.0 ? viewportLocalX : viewportLocalX / zoomFactor;
  final localY =
      zoomFactor == 1.0 ? viewportLocalY : viewportLocalY / zoomFactor;
  final boxWidth =
      zoomFactor == 1.0 ? viewportWidth : viewportWidth / zoomFactor;
  final boxHeight =
      zoomFactor == 1.0 ? viewportHeight : viewportHeight / zoomFactor;

  // --- Vertical: find hit row ---
  final vController = _verticalIndexedController.controller;
  final vOffset = vController.hasClients ? vController.offset : 0.0;
  final vMinExtent =
      vController.hasClients ? vController.position.minScrollExtent : 0.0;

  // Content-space Y = viewport Y minus header, plus scroll offset.
  final contentY = localY - headerH + vOffset;
  final dataViewportHeight = boxHeight - headerH;

  int? rowIndex;
  double rowProgress = 0.0;
  bool isFirstVisibleRow = false;
  bool isLastVisibleRow = false;
  bool canScrollUp = false;
  bool canScrollDown = false;
  int? firstVisible;
  int? lastVisible;

  if (contentY >= 0 && totalRows > 0) {
    double cumulative = 0.0;
    for (int i = 0; i < totalRows; i++) {
      final h = _rowHeightAt(i);
      if (firstVisible == null && cumulative + h > vOffset) {
        firstVisible = i;
      }
      if (firstVisible != null &&
          lastVisible == null &&
          cumulative + h > vOffset + dataViewportHeight) {
        lastVisible = i > 0 ? i - 1 : i;
        // Handle the edge case where the last visible row is the very first row
        // that is partially visible at the bottom
        if (lastVisible < firstVisible) {
          lastVisible = firstVisible;
        }
      }
      if (contentY < cumulative + h) {
        rowIndex = i;
        rowProgress = ((contentY - cumulative) / h).clamp(0.0, 1.0);
        break;
      }
      cumulative += h;
    }
    // If we never found a "last visible" because content fits entirely,
    // the last visible is the last existing row.
    if (firstVisible != null && lastVisible == null) {
      lastVisible = totalRows - 1;
    }
    // If contentY is beyond the last row, clamp to last row
    if (rowIndex == null && totalRows > 0 && contentY >= 0) {
      rowIndex = totalRows - 1;
      rowProgress = 1.0;
    }

    canScrollUp = vOffset > vMinExtent;
    canScrollDown = lastVisible != null && lastVisible < totalRows - 1;
    if (rowIndex != null) {
      isFirstVisibleRow = firstVisible != null && rowIndex == firstVisible;
      isLastVisibleRow = lastVisible != null && rowIndex == lastVisible;
    }
  }

  // --- Horizontal: find hit column ---
  final hMetrics = widget.horizontalSyncController?.value;
  final hOffset = hMetrics?.offset ?? 0.0;
  final hController = hMetrics?.controller;
  final hMinExtent = (hController != null && hController.hasClients)
      ? hController.position.minScrollExtent
      : 0.0;

  // Content-space X = viewport X minus row-header, plus horizontal offset.
  final contentX = localX - rowHeaderW + hOffset;
  final dataViewportWidth = boxWidth - rowHeaderW;

  int? columnIndex;
  double columnProgress = 0.0;
  bool isFirstVisibleColumn = false;
  bool isLastVisibleColumn = false;
  bool canScrollLeft = false;
  bool canScrollRight = false;
  int? firstVisibleCol;
  int? lastVisibleCol;

  if (contentX >= 0 && totalColumns > 0) {
    double cumulative = 0.0;
    for (int j = 0; j < totalColumns; j++) {
      final w = _columnWidthAt(j);
      if (firstVisibleCol == null && cumulative + w > hOffset) {
        firstVisibleCol = j;
      }
      if (firstVisibleCol != null &&
          lastVisibleCol == null &&
          cumulative + w > hOffset + dataViewportWidth) {
        lastVisibleCol = j > 0 ? j - 1 : j;
        if (lastVisibleCol < firstVisibleCol) {
          lastVisibleCol = firstVisibleCol;
        }
      }
      if (contentX < cumulative + w) {
        columnIndex = j;
        columnProgress = ((contentX - cumulative) / w).clamp(0.0, 1.0);
        break;
      }
      cumulative += w;
    }
    if (firstVisibleCol != null && lastVisibleCol == null) {
      lastVisibleCol = totalColumns - 1;
    }
    if (columnIndex == null && totalColumns > 0 && contentX >= 0) {
      columnIndex = totalColumns - 1;
      columnProgress = 1.0;
    }

    canScrollLeft = hOffset > hMinExtent;
    canScrollRight =
        lastVisibleCol != null && lastVisibleCol < totalColumns - 1;
    if (columnIndex != null) {
      isFirstVisibleColumn =
          firstVisibleCol != null && columnIndex == firstVisibleCol;
      isLastVisibleColumn =
          lastVisibleCol != null && columnIndex == lastVisibleCol;
    }
  }

  return SSpreadsheetHitResult(
    rowIndex: rowIndex,
    rowProgress: rowProgress,
    columnIndex: columnIndex,
    columnProgress: columnProgress,
    isInDataArea: rowIndex != null && columnIndex != null,
    totalRows: totalRows,
    totalColumns: totalColumns,
    isFirstVisibleRow: isFirstVisibleRow,
    isLastVisibleRow: isLastVisibleRow,
    canScrollUp: canScrollUp,
    canScrollDown: canScrollDown,
    isFirstVisibleColumn: isFirstVisibleColumn,
    isLastVisibleColumn: isLastVisibleColumn,
    canScrollLeft: canScrollLeft,
    canScrollRight: canScrollRight,
  );
}