From 93c69b6510a4aaf734d08f17587e81db3d79ca70 Mon Sep 17 00:00:00 2001 From: xuhengyu Date: Sat, 20 Jun 2026 20:47:05 +0800 Subject: [PATCH] perf(datagrid): cache column index lookup in selection drawing --- CHANGELOG.md | 1 + .../Views/Results/DataGridCoordinator.swift | 23 +++ TablePro/Views/Results/DataGridRowView.swift | 11 +- TablePro/Views/Results/DataGridView.swift | 1 + .../Extensions/DataGridView+Selection.swift | 1 + .../Selection/GridSelectionOverlay.swift | 9 +- .../Extensions/ColumnIndexCacheTests.swift | 149 ++++++++++++++++++ 7 files changed, 184 insertions(+), 11 deletions(-) create mode 100644 TableProTests/Views/Results/Extensions/ColumnIndexCacheTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 553ae5ded..d1513f56b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Drag-selecting many columns in a wide result set scrolls smoothly instead of lagging; the selection overlay and row highlight now look up column positions from a cache that refreshes when columns are added, removed, or reordered. - Data grid now serves the row count from its existing cache instead of recomputing it on every layout pass, reducing CPU churn while scrolling large result sets. - Typing in the sidebar table search stays responsive on databases with thousands of tables; filtering runs after a short pause instead of on every keystroke. diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index e9381730a..988455fae 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -38,6 +38,27 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData private(set) var identitySchema: ColumnIdentitySchema = .empty var currentSortState = SortState() + private var columnIndexByDataIndex: [Int: Int] = [:] + private static let selectionCacheLogger = Logger(subsystem: "com.TablePro", category: "DataGrid.ColumnIndexCache") + + func tableColumnIndex(for dataIndex: Int) -> Int? { + if let cached = columnIndexByDataIndex[dataIndex] { + return cached + } + guard let tableView, + let identifier = identitySchema.identifier(for: dataIndex) else { return nil } + let resolved = tableView.column(withIdentifier: identifier) + guard resolved >= 0 else { return nil } + columnIndexByDataIndex[dataIndex] = resolved + return resolved + } + + func invalidateColumnIndexCache() { + guard !columnIndexByDataIndex.isEmpty else { return } + Self.selectionCacheLogger.debug("invalidate column index cache (had \(self.columnIndexByDataIndex.count))") + columnIndexByDataIndex.removeAll() + } + func columnIdentifier(for dataIndex: Int) -> NSUserInterfaceItemIdentifier? { identitySchema.identifier(for: dataIndex) } @@ -224,6 +245,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData columnDisplayFormats = [] cachedRowCount = 0 cachedColumnCount = 0 + invalidateColumnIndexCache() sortedIDs = nil lastUpdateSnapshot = nil columnPool.detachFromTableView() @@ -666,6 +688,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData guard schemaChanged else { return false } identitySchema = nextSchema displayCache.removeAll() + invalidateColumnIndexCache() return true } diff --git a/TablePro/Views/Results/DataGridRowView.swift b/TablePro/Views/Results/DataGridRowView.swift index 6217ed938..4db478c95 100644 --- a/TablePro/Views/Results/DataGridRowView.swift +++ b/TablePro/Views/Results/DataGridRowView.swift @@ -99,9 +99,10 @@ class DataGridRowView: NSTableRowView { } private func drawCellSelectionFill(in dirtyRect: NSRect) { - guard let selection = coordinator?.selectionController.selection, - !selection.isEmpty, - let tableView = coordinator?.tableView else { return } + guard let coordinator, + let tableView = coordinator.tableView else { return } + let selection = coordinator.selectionController.selection + guard !selection.isEmpty else { return } let columns = selection.columns(in: rowIndex) guard !columns.isEmpty else { return } @@ -110,10 +111,8 @@ class DataGridRowView: NSTableRowView { : NSColor.selectedContentBackgroundColor.withAlphaComponent(0.28) fillColor.setFill() - let schema = coordinator?.identitySchema for dataColumn in columns { - guard let schema, - let tableColumnIndex = DataGridView.tableColumnIndex(for: dataColumn, in: tableView, schema: schema) else { continue } + guard let tableColumnIndex = coordinator.tableColumnIndex(for: dataColumn) else { continue } let columnRect = tableView.rect(ofColumn: tableColumnIndex) let localRect = NSRect(x: columnRect.minX, y: 0, width: columnRect.width, height: bounds.height) guard localRect.intersects(dirtyRect) else { continue } diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 22906b994..44bfc7b48 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -271,6 +271,7 @@ struct DataGridView: NSViewRepresentable { savedLayout: savedLayout ) coordinator.isRebuildingColumns = false + coordinator.invalidateColumnIndexCache() if savedLayout == nil { coordinator.scheduleLayoutPersist() diff --git a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift index f4e3fe60b..9bb56063a 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift @@ -14,6 +14,7 @@ extension TableViewCoordinator { func tableViewColumnDidMove(_ notification: Notification) { guard !isRebuildingColumns else { return } + invalidateColumnIndexCache() layoutPersistTask?.cancel() persistColumnLayoutToStorage() } diff --git a/TablePro/Views/Results/Selection/GridSelectionOverlay.swift b/TablePro/Views/Results/Selection/GridSelectionOverlay.swift index d95aff893..920cfbd18 100644 --- a/TablePro/Views/Results/Selection/GridSelectionOverlay.swift +++ b/TablePro/Views/Results/Selection/GridSelectionOverlay.swift @@ -34,13 +34,12 @@ final class GridSelectionOverlay: NSView { override func draw(_ dirtyRect: NSRect) { guard let tableView, let coordinator else { return } - let schema = coordinator.identitySchema let totalRows = tableView.numberOfRows let editingCell = activeOverlayCell(in: coordinator) NSColor.selectedContentBackgroundColor.withAlphaComponent(Self.borderAlpha).setStroke() for rect in selection.rectangles { - guard let frame = frame(for: rect, in: tableView, schema: schema) else { continue } + guard let frame = frame(for: rect, in: tableView, coordinator: coordinator) else { continue } guard frame.intersects(dirtyRect) else { continue } if isFullHeight(rect, totalRows: totalRows) { continue } if let editingCell, rect.contains(editingCell) { continue } @@ -53,7 +52,7 @@ final class GridSelectionOverlay: NSView { if let active = selection.activeCell, editingCell != active, selection.rectangles.count > 1 || (selection.rectangles.first?.rows.count ?? 0) > 1 || (selection.rectangles.first?.columns.count ?? 0) > 1, - let frame = frame(for: GridRect(cell: active), in: tableView, schema: schema), + let frame = frame(for: GridRect(cell: active), in: tableView, coordinator: coordinator), frame.intersects(dirtyRect) { NSColor.controlAccentColor.setStroke() let inset = frame.insetBy(dx: Self.activeCellBorderWidth / 2, dy: Self.activeCellBorderWidth / 2) @@ -78,7 +77,7 @@ final class GridSelectionOverlay: NSView { return rect.rows.lowerBound <= 0 && rect.rows.upperBound >= totalRows - 1 } - private func frame(for rect: GridRect, in tableView: NSTableView, schema: ColumnIdentitySchema) -> NSRect? { + private func frame(for rect: GridRect, in tableView: NSTableView, coordinator: TableViewCoordinator) -> NSRect? { guard tableView.numberOfRows > 0, tableView.numberOfColumns > 0 else { return nil } let firstRow = max(0, rect.rows.lowerBound) let lastRow = min(tableView.numberOfRows - 1, rect.rows.upperBound) @@ -92,7 +91,7 @@ final class GridSelectionOverlay: NSView { var leadingX = CGFloat.infinity var trailingX = -CGFloat.infinity for dataColumn in rect.columns.lowerBound...rect.columns.upperBound { - guard let tableColumnIndex = DataGridView.tableColumnIndex(for: dataColumn, in: tableView, schema: schema) else { continue } + guard let tableColumnIndex = coordinator.tableColumnIndex(for: dataColumn) else { continue } let columnRect = tableView.rect(ofColumn: tableColumnIndex) leadingX = min(leadingX, columnRect.minX) trailingX = max(trailingX, columnRect.maxX) diff --git a/TableProTests/Views/Results/Extensions/ColumnIndexCacheTests.swift b/TableProTests/Views/Results/Extensions/ColumnIndexCacheTests.swift new file mode 100644 index 000000000..d37bf5e7b --- /dev/null +++ b/TableProTests/Views/Results/Extensions/ColumnIndexCacheTests.swift @@ -0,0 +1,149 @@ +import AppKit +import Foundation +import SwiftUI +@testable import TablePro +import Testing + +@MainActor +private final class StubColumnLayoutPersister: ColumnLayoutPersisting { + func load(for tableName: String, connectionId: UUID) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) {} + func clear(for tableName: String, connectionId: UUID) {} +} + +@Suite("TableViewCoordinator column index cache") +@MainActor +struct ColumnIndexCacheTests { + private func makeCoordinator() -> TableViewCoordinator { + TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: false, + selectedRowIndices: .constant([]), + delegate: nil, + layoutPersister: StubColumnLayoutPersister() + ) + } + + private func attachColumns(_ tableView: NSTableView, count: Int) { + tableView.addTableColumn( + NSTableColumn(identifier: ColumnIdentitySchema.rowNumberIdentifier) + ) + for slot in 0..