diff --git a/CHANGELOG.md b/CHANGELOG.md index e7f2e12cd..20c5cb815 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. - The connection Export Options dialog keeps a steady size when you turn on Include Credentials, and saves through the standard macOS save dialog. - 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..ba61ea7d5 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() @@ -477,7 +499,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData switch delta { case .cellChanged(let row, let column): guard let tableView, - let tableColumn = DataGridView.tableColumnIndex(for: column, in: tableView, schema: identitySchema) + let tableColumn = tableColumnIndex(for: column) else { return } guard row >= 0, row < tableView.numberOfRows else { return } invalidateDisplayCache(forDisplayRow: row, column: column) @@ -494,11 +516,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData if position.row >= 0, position.row < tableView.numberOfRows { rowSet.insert(position.row) } - if let tableColumn = DataGridView.tableColumnIndex( - for: position.column, - in: tableView, - schema: identitySchema - ) { + if let tableColumn = tableColumnIndex(for: position.column) { colSet.insert(tableColumn) } invalidateDisplayCache(forDisplayRow: position.row, column: position.column) @@ -620,7 +638,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData func beginEditing(displayRow: Int, column: Int) { guard let tableView, - let displayCol = DataGridView.tableColumnIndex(for: column, in: tableView, schema: identitySchema) + let displayCol = tableColumnIndex(for: column) else { return } guard displayRow >= 0, displayRow < tableView.numberOfRows else { return } tableView.scrollRowToVisible(displayRow) @@ -666,6 +684,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData guard schemaChanged else { return false } identitySchema = nextSchema displayCache.removeAll() + invalidateColumnIndexCache() return true } @@ -728,11 +747,7 @@ extension TableViewCoordinator: DataGridCellAccessoryDelegate { func dataGridCellDidDoubleClick(row: Int, columnIndex: Int) { guard row >= 0, columnIndex >= 0, let tableView else { return } - guard let tableColumn = DataGridView.tableColumnIndex( - for: columnIndex, - in: tableView, - schema: identitySchema - ) else { return } + guard let tableColumn = tableColumnIndex(for: columnIndex) else { return } handleCellInteraction(row: row, tableColumn: tableColumn, columnIndex: columnIndex, tableView: tableView) } } diff --git a/TablePro/Views/Results/DataGridRowView.swift b/TablePro/Views/Results/DataGridRowView.swift index 6217ed938..31ec870d7 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 } @@ -531,11 +530,7 @@ class DataGridRowView: NSTableRowView { @objc private func previewForeignKey(_ sender: NSMenuItem) { guard let columnIndex = sender.representedObject as? Int, let coordinator, let tableView = coordinator.tableView, - let column = DataGridView.tableColumnIndex( - for: columnIndex, - in: tableView, - schema: coordinator.identitySchema - ) else { return } + let column = coordinator.tableColumnIndex(for: columnIndex) else { return } coordinator.showForeignKeyPreview( tableView: tableView, row: rowIndex, column: column, columnIndex: columnIndex ) diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 22906b994..ff43675f5 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() @@ -400,16 +401,6 @@ struct DataGridView: NSViewRepresentable { tableColumnIndex >= firstDataTableColumnIndex } - static func tableColumnIndex( - for dataIndex: Int, - in tableView: NSTableView, - schema: ColumnIdentitySchema - ) -> Int? { - guard let identifier = schema.identifier(for: dataIndex) else { return nil } - let index = tableView.column(withIdentifier: identifier) - return index >= 0 ? index : nil - } - static func dataColumnIndex( for tableColumnIndex: Int, in tableView: NSTableView, diff --git a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift index a24b5f5fd..254139960 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift @@ -21,11 +21,7 @@ extension TableViewCoordinator { invalidateDisplayCache() visualIndex.updateRow(row, from: changeManager, sortedIDs: sortedIDs) - guard let tableColumnIndex = DataGridView.tableColumnIndex( - for: columnIndex, - in: tableView, - schema: identitySchema - ) else { return } + guard let tableColumnIndex = tableColumnIndex(for: columnIndex) else { return } tableView.reloadData( forRowIndexes: IndexSet(integer: row), columnIndexes: IndexSet(integer: tableColumnIndex) diff --git a/TablePro/Views/Results/Extensions/DataGridView+Click.swift b/TablePro/Views/Results/Extensions/DataGridView+Click.swift index 68769773d..6a269203c 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Click.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Click.swift @@ -65,11 +65,7 @@ extension TableViewCoordinator { guard row >= 0, columnIndex >= 0 else { return } guard !changeManager.isRowDeleted(row) else { return } guard let tableView else { return } - guard let column = DataGridView.tableColumnIndex( - for: columnIndex, - in: tableView, - schema: identitySchema - ) else { return } + guard let column = tableColumnIndex(for: columnIndex) else { return } if let dropdownCols = dropdownColumns, dropdownCols.contains(columnIndex) { showDropdownMenu(tableView: tableView, row: row, column: column, columnIndex: columnIndex) diff --git a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift index 89b877133..43cc89baa 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift @@ -56,11 +56,7 @@ extension TableViewCoordinator { let isFocused: Bool = { guard let keyTableView = tableView as? KeyHandlingTableView, keyTableView.focusedRow == row, - let tableColumnIndex = DataGridView.tableColumnIndex( - for: columnIndex, - in: tableView, - schema: identitySchema - ), + let tableColumnIndex = tableColumnIndex(for: columnIndex), keyTableView.focusedColumn == tableColumnIndex else { return false } return true }() diff --git a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift index 238dfd8d6..5228ae818 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift @@ -82,11 +82,7 @@ extension TableViewCoordinator { let focusedRow = (tableView as? KeyHandlingTableView)?.focusedRow ?? -1 let newRow = focusedRow >= 0 ? focusedRow : (tableView.selectedRowIndexes.max() ?? -1) guard newRow >= 0, - let tableColumnIndex = DataGridView.tableColumnIndex( - for: columnIndex, - in: tableView, - schema: identitySchema - ) else { + let tableColumnIndex = tableColumnIndex(for: columnIndex) else { popover.close() clearFKPreviewState() return 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/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index d9b92c23d..2e7a53ea0 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -162,7 +162,7 @@ final class KeyHandlingTableView: NSTableView { selectRowIndexes(IndexSet(integer: activeCoord.row), byExtendingSelection: false) } focusedRow = activeCoord.row - focusedColumn = DataGridView.tableColumnIndex(for: activeCoord.column, in: self, schema: schema) ?? clickedColumn + focusedColumn = coordinator?.tableColumnIndex(for: activeCoord.column) ?? clickedColumn case .clearFocus: deselectAll(nil) focusedRow = -1 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..