hitTest method
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,
);
}