diff --git a/TablePro/Core/KeyboardHandling/KeyCode.swift b/TablePro/Core/KeyboardHandling/KeyCode.swift index e5d6642e1..eb0c08d3d 100644 --- a/TablePro/Core/KeyboardHandling/KeyCode.swift +++ b/TablePro/Core/KeyboardHandling/KeyCode.swift @@ -68,6 +68,20 @@ public enum KeyCode: UInt16 { /// Right arrow case rightArrow = 124 + // MARK: - Navigation Keys + + /// Home key + case home = 115 + + /// End key + case end = 119 + + /// Page Up key + case pageUp = 116 + + /// Page Down key + case pageDown = 121 + // MARK: - Letter Keys (for Cmd+ shortcuts) case a = 0 diff --git a/TablePro/Models/Query/ResultSet.swift b/TablePro/Models/Query/ResultSet.swift index 61e7450a8..e93f2cde5 100644 --- a/TablePro/Models/Query/ResultSet.swift +++ b/TablePro/Models/Query/ResultSet.swift @@ -28,12 +28,30 @@ final class ResultSet: Identifiable { var pagination = PaginationState() var columnLayout = ColumnLayoutState() - // Column metadata - var columnTypes: [ColumnType] = [] - var columnDefaults: [String: String?] = [:] - var columnForeignKeys: [String: ForeignKeyInfo] = [:] - var columnEnumValues: [String: [String]] = [:] - var columnNullable: [String: Bool] = [:] + var columnTypes: [ColumnType] { + get { rowBuffer.columnTypes } + set { rowBuffer.columnTypes = newValue } + } + + var columnDefaults: [String: String?] { + get { rowBuffer.columnDefaults } + set { rowBuffer.columnDefaults = newValue } + } + + var columnForeignKeys: [String: ForeignKeyInfo] { + get { rowBuffer.columnForeignKeys } + set { rowBuffer.columnForeignKeys = newValue } + } + + var columnEnumValues: [String: [String]] { + get { rowBuffer.columnEnumValues } + set { rowBuffer.columnEnumValues = newValue } + } + + var columnNullable: [String: Bool] { + get { rowBuffer.columnNullable } + set { rowBuffer.columnNullable = newValue } + } var resultColumns: [String] { rowBuffer.columns } var resultRows: [[String?]] { rowBuffer.rows } diff --git a/TablePro/Models/Query/RowProvider.swift b/TablePro/Models/Query/RowProvider.swift index 874e82807..9bdab27db 100644 --- a/TablePro/Models/Query/RowProvider.swift +++ b/TablePro/Models/Query/RowProvider.swift @@ -354,140 +354,3 @@ final class InMemoryRowProvider: RowProvider { return safeBuffer.rows[displayIndex] } } - -// MARK: - Database Row Provider (for virtualized access via driver) - -/// Row provider that fetches data on-demand from database. -/// Cache is bounded to `maxCacheSize` entries; oldest entries by row index -/// are evicted when the limit is exceeded. -final class DatabaseRowProvider: RowProvider { - private static let logger = Logger(subsystem: "com.TablePro", category: "RowProvider") - private static let maxCacheSize = 10_000 - - private let driver: DatabaseDriver - private let baseQuery: String - private var cache: [Int: TableRowData] = [:] - private let pageSize: Int - private var prefetchTask: Task? - private var inFlightRange: Range? - - private(set) var totalRowCount: Int = 0 - private(set) var columns: [String] - private(set) var columnDefaults: [String: String?] - - private var isInitialized = false - - init(driver: DatabaseDriver, query: String, columns: [String], columnDefaults: [String: String?] = [:], pageSize: Int = 200) { - self.driver = driver - self.baseQuery = query - self.columns = columns - self.columnDefaults = columnDefaults - self.pageSize = pageSize - } - - /// Initialize by fetching total row count - func initialize() async throws { - guard !isInitialized else { return } - - totalRowCount = try await driver.fetchRowCount(query: baseQuery) - isInitialized = true - } - - func fetchRows(offset: Int, limit: Int) -> [TableRowData] { - var result: [TableRowData] = [] - - for i in offset.. TableRowData? { - cache[index] - } - - /// Update a cached cell value - func updateValue(_ value: String?, at rowIndex: Int, columnIndex: Int) { - cache[rowIndex]?.setValue(value, at: columnIndex) - } - - // MARK: - Private - - /// Evict entries when cache exceeds `maxCacheSize`. - /// Keeps the half of entries closest to `nearIndex` (the current access window) - /// and discards the rest. - private func evictCacheIfNeeded(nearIndex: Int) { - guard cache.count > Self.maxCacheSize else { return } - let halfSize = Self.maxCacheSize / 2 - cache = cache.filter { abs($0.key - nearIndex) <= halfSize } - } -} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 16d622053..ef05e0f87 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -293,11 +293,6 @@ extension MainContentCoordinator { rs.isEditable = updatedTab.tableContext.isEditable rs.resultVersion = updatedTab.resultVersion rs.metadataVersion = updatedTab.metadataVersion - rs.columnTypes = updatedTab.columnTypes - rs.columnDefaults = updatedTab.columnDefaults - rs.columnForeignKeys = updatedTab.columnForeignKeys - rs.columnEnumValues = updatedTab.columnEnumValues - rs.columnNullable = updatedTab.columnNullable // Keep pinned results, replace unpinned let pinned = updatedTab.display.resultSets.filter(\.isPinned) diff --git a/TablePro/Views/Results/CellOverlayEditor.swift b/TablePro/Views/Results/CellOverlayEditor.swift index 3d1e22039..0fbe3e61c 100644 --- a/TablePro/Views/Results/CellOverlayEditor.swift +++ b/TablePro/Views/Results/CellOverlayEditor.swift @@ -3,20 +3,16 @@ // TablePro // // Overlay editor for multiline cell values. -// Uses NSScrollView + NSTextView positioned on top of the cell, +// Uses a borderless NSPanel containing an NSScrollView + NSTextView, // bypassing NSTextFieldCell's field editor which cannot scroll vertically. // import AppKit -/// Overlay editor that displays a scrollable NSTextView on top of a data grid cell -/// for editing multiline content. Commits on Enter, cancels on Escape, and -/// navigates cells with Tab/Shift+Tab. @MainActor final class CellOverlayEditor: NSObject, NSTextViewDelegate { - private var scrollView: NSScrollView? + private var panel: CellOverlayPanel? private weak var tableView: NSTableView? - private var clickMonitor: Any? private var scrollObserver: NSObjectProtocol? private var columnResizeObserver: NSObjectProtocol? @@ -24,18 +20,14 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate { private(set) var column: Int = -1 private(set) var columnIndex: Int = -1 - /// Called with the new string value when the edit is committed var onCommit: ((_ row: Int, _ columnIndex: Int, _ newValue: String) -> Void)? - /// Called when the user presses Tab or Shift+Tab to navigate var onTabNavigation: ((_ row: Int, _ column: Int, _ forward: Bool) -> Void)? - /// Whether the overlay is currently active - var isActive: Bool { scrollView != nil } + var isActive: Bool { panel != nil } // MARK: - Show / Dismiss - /// Show the overlay editor on top of the specified cell func show( in tableView: NSTableView, row: Int, @@ -51,29 +43,30 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate { self.columnIndex = columnIndex guard let cellView = tableView.view(atColumn: column, row: row, makeIfNecessary: false) else { return } + guard let window = tableView.window else { return } - // Convert cell frame to table view coordinates - let cellRect = cellView.convert(cellView.bounds, to: tableView) + let cellRectInWindow = cellView.convert(cellView.bounds, to: nil) + let cellRectOnScreen = window.convertToScreen(cellRectInWindow) - // Determine overlay height — at least the cell height, up to 120pt let lineHeight: CGFloat = ThemeEngine.shared.dataGridFonts.regular.boundingRectForFont.height + 4 var newlineCount = 0 for scalar in value.unicodeScalars where scalar == "\n" { newlineCount += 1 } let lineCount = CGFloat(newlineCount + 1) - let contentHeight = max(lineCount * lineHeight + 8, cellRect.height) + let contentHeight = max(lineCount * lineHeight + 8, cellRectOnScreen.height) let overlayHeight = min(contentHeight, 120) - let overlayRect = NSRect( - x: cellRect.origin.x, - y: cellRect.origin.y, - width: cellRect.width, + let panelRect = NSRect( + x: cellRectOnScreen.origin.x, + y: cellRectOnScreen.origin.y - (overlayHeight - cellRectOnScreen.height), + width: cellRectOnScreen.width, height: overlayHeight ) - // Build text view - let textView = OverlayTextView(frame: NSRect(origin: .zero, size: overlayRect.size)) + let contentSize = NSSize(width: panelRect.width, height: panelRect.height) + + let textView = OverlayTextView(frame: NSRect(origin: .zero, size: contentSize)) textView.overlayEditor = self textView.isRichText = false textView.allowsUndo = true @@ -84,46 +77,51 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate { textView.isHorizontallyResizable = false textView.textContainer?.widthTracksTextView = true textView.textContainer?.containerSize = NSSize( - width: overlayRect.width, + width: contentSize.width, height: CGFloat.greatestFiniteMagnitude ) textView.delegate = self textView.string = value textView.selectAll(nil) - // Build scroll view - let sv = NSScrollView(frame: overlayRect) - sv.hasVerticalScroller = true - sv.hasHorizontalScroller = false - sv.autohidesScrollers = true - sv.borderType = .noBorder - sv.documentView = textView - sv.drawsBackground = true - sv.backgroundColor = .textBackgroundColor - - // Visual border to indicate editing state - sv.wantsLayer = true - sv.layer?.borderWidth = 2 - sv.layer?.borderColor = NSColor.selectedControlColor.safeCGColor - sv.layer?.cornerRadius = 2 - - tableView.addSubview(sv) - scrollView = sv - - // Make text view first responder - tableView.window?.makeFirstResponder(textView) - - // Install click-outside monitor - clickMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in - guard let self, let sv = self.scrollView, sv.window != nil else { return event } - let locationInSV = sv.convert(event.locationInWindow, from: nil) - if !sv.bounds.contains(locationInSV) { - self.dismiss(commit: true) - } - return event + let scrollView = NSScrollView(frame: NSRect(origin: .zero, size: contentSize)) + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.borderType = .noBorder + scrollView.documentView = textView + scrollView.drawsBackground = true + scrollView.backgroundColor = .textBackgroundColor + scrollView.autoresizingMask = [.width, .height] + + let newPanel = CellOverlayPanel( + contentRect: panelRect, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + newPanel.level = .floating + newPanel.hidesOnDeactivate = false + newPanel.isReleasedWhenClosed = false + newPanel.hasShadow = true + newPanel.backgroundColor = .textBackgroundColor + newPanel.isOpaque = false + newPanel.contentView = scrollView + newPanel.contentView?.wantsLayer = true + newPanel.contentView?.layer?.borderWidth = 2 + newPanel.contentView?.layer?.borderColor = NSColor.selectedControlColor.safeCGColor + newPanel.contentView?.layer?.cornerRadius = 2 + newPanel.contentView?.layer?.masksToBounds = true + + newPanel.onResignKey = { [weak self] in + self?.dismiss(commit: true) } - // Observe table scroll → commit and dismiss + panel = newPanel + + newPanel.makeKeyAndOrderFront(nil) + newPanel.makeFirstResponder(textView) + if let clipView = tableView.enclosingScrollView?.contentView { scrollObserver = NotificationCenter.default.addObserver( forName: NSView.boundsDidChangeNotification, @@ -136,7 +134,6 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate { } } - // Observe column resize → commit and dismiss columnResizeObserver = NotificationCenter.default.addObserver( forName: NSTableView.columnDidResizeNotification, object: tableView, @@ -148,17 +145,15 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate { } } - /// Dismiss the overlay, optionally committing the current text func dismiss(commit: Bool) { - guard let sv = scrollView, let textView = sv.documentView as? NSTextView else { return } + guard let activePanel = panel, + let scrollView = activePanel.contentView as? NSScrollView, + let textView = scrollView.documentView as? NSTextView else { return } let newValue = textView.string - // Remove observers before tearing down - if let monitor = clickMonitor { - NSEvent.removeMonitor(monitor) - clickMonitor = nil - } + activePanel.onResignKey = nil + if let observer = scrollObserver { NotificationCenter.default.removeObserver(observer) scrollObserver = nil @@ -168,14 +163,13 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate { columnResizeObserver = nil } - // Restore first responder to table view before removing overlay + activePanel.orderOut(nil) + panel = nil + if let tableView { tableView.window?.makeFirstResponder(tableView) } - sv.removeFromSuperview() - scrollView = nil - if commit { onCommit?(row, columnIndex, newValue) } @@ -184,9 +178,7 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate { // MARK: - NSTextViewDelegate func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { - // Enter → commit if commandSelector == #selector(NSResponder.insertNewline(_:)) { - // Option+Enter → insert actual newline if NSApp.currentEvent?.modifierFlags.contains(.option) == true { textView.insertNewlineIgnoringFieldEditor(nil) return true @@ -195,13 +187,11 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate { return true } - // Escape → cancel if commandSelector == #selector(NSResponder.cancelOperation(_:)) { dismiss(commit: false) return true } - // Tab → commit and navigate forward if commandSelector == #selector(NSResponder.insertTab(_:)) { let r = row, c = column dismiss(commit: true) @@ -209,7 +199,6 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate { return true } - // Shift+Tab → commit and navigate backward if commandSelector == #selector(NSResponder.insertBacktab(_:)) { let r = row, c = column dismiss(commit: true) @@ -217,20 +206,28 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate { return true } - // Up/Down arrows — let NSTextView handle natively for line navigation return false } } +// MARK: - Overlay Panel + +private final class CellOverlayPanel: NSPanel { + var onResignKey: (() -> Void)? + + override var canBecomeKey: Bool { true } + + override func resignKey() { + super.resignKey() + onResignKey?() + } +} + // MARK: - Overlay Text View -/// NSTextView subclass that commits and dismisses the overlay editor when -/// the user presses a menu key equivalent (e.g. Cmd+S) so the shortcut -/// propagates to the SwiftUI menu system instead of being swallowed. private final class OverlayTextView: NSTextView { weak var overlayEditor: CellOverlayEditor? - /// Key equivalents that should commit the edit and bubble up to the menu bar. private static let menuKeyEquivalents: Set = ["s"] override func performKeyEquivalent(with event: NSEvent) -> Bool { diff --git a/TablePro/Views/Results/DataGridCellFactory.swift b/TablePro/Views/Results/DataGridCellFactory.swift index 5290e2ccc..f34e6deb6 100644 --- a/TablePro/Views/Results/DataGridCellFactory.swift +++ b/TablePro/Views/Results/DataGridCellFactory.swift @@ -270,7 +270,7 @@ final class DataGridCellFactory { CATransaction.commit() - if !isLargeDataset && Self.cachedVoiceOverEnabled { + if Self.cachedVoiceOverEnabled { let accessibilityValue = rawValue ?? String(localized: "NULL") cell.setAccessibilityLabel( String(format: String(localized: "Row %d, column %d: %@"), row + 1, columnIndex + 1, accessibilityValue) diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 31879e868..d6ab26d16 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -53,7 +53,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var widths: [String: CGFloat] = [:] var order: [String] = [] for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { - guard let colIndex = DataGridView.columnIndex(from: column.identifier), + guard let colIndex = DataGridView.dataColumnIndex(from: column.identifier), colIndex < rowProvider.columns.count else { continue } let name = rowProvider.columns[colIndex] widths[name] = column.width diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 7e00c3dc6..b6932424a 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -123,7 +123,7 @@ struct DataGridView: NSViewRepresentable { // Add data columns (suppress resize notifications during setup) context.coordinator.isRebuildingColumns = true for (index, columnName) in rowProvider.columns.enumerated() { - let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("col_\(index)")) + let column = NSTableColumn(identifier: Self.columnIdentifier(for: index)) column.title = columnName if index < rowProvider.columnTypes.count { let typeName = rowProvider.columnTypes[index].rawType ?? rowProvider.columnTypes[index].displayName @@ -140,14 +140,17 @@ struct DataGridView: NSViewRepresentable { column.minWidth = 30 column.resizingMask = .userResizingMask column.isEditable = isEditable - column.sortDescriptorPrototype = NSSortDescriptor(key: "col_\(index)", ascending: true) + column.sortDescriptorPrototype = NSSortDescriptor( + key: Self.columnIdentifier(for: index).rawValue, + ascending: true + ) tableView.addTableColumn(column) } // Apply saved column widths (from user resizing) if !columnLayout.columnWidths.isEmpty { for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { - guard let colIndex = Self.columnIndex(from: column.identifier), + guard let colIndex = Self.dataColumnIndex(from: column.identifier), colIndex < rowProvider.columns.count else { continue } let baseName = rowProvider.columns[colIndex] if let savedWidth = columnLayout.columnWidths[baseName] { @@ -320,7 +323,7 @@ struct DataGridView: NSViewRepresentable { // Check if columns changed (by name or structure) let currentDataColumns = tableView.tableColumns.dropFirst() let currentColumnIds = currentDataColumns.map { $0.identifier.rawValue } - let expectedColumnIds = rowProvider.columns.indices.map { "col_\($0)" } + let expectedColumnIds = rowProvider.columns.indices.map { Self.columnIdentifier(for: $0).rawValue } let columnsChanged = !rowProvider.columns.isEmpty && (currentColumnIds != expectedColumnIds) // Only recalculate column widths when transitioning from 0 rows (initial data load). @@ -377,7 +380,7 @@ struct DataGridView: NSViewRepresentable { let willRestoreWidths = !columnLayout.columnWidths.isEmpty for (index, columnName) in rowProvider.columns.enumerated() { - let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("col_\(index)")) + let column = NSTableColumn(identifier: Self.columnIdentifier(for: index)) column.title = columnName if index < rowProvider.columnTypes.count { let typeName = rowProvider.columnTypes[index].rawType @@ -399,14 +402,17 @@ struct DataGridView: NSViewRepresentable { column.minWidth = 30 column.resizingMask = .userResizingMask column.isEditable = isEditable - column.sortDescriptorPrototype = NSSortDescriptor(key: "col_\(index)", ascending: true) + column.sortDescriptorPrototype = NSSortDescriptor( + key: Self.columnIdentifier(for: index).rawValue, + ascending: true + ) tableView.addTableColumn(column) } } else { // Same column count — lightweight in-place update (avoids remove/add overhead) let hasSavedWidths = !columnLayout.columnWidths.isEmpty for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { - guard let colIndex = Self.columnIndex(from: column.identifier), + guard let colIndex = Self.dataColumnIndex(from: column.identifier), colIndex < rowProvider.columns.count else { continue } let columnName = rowProvider.columns[colIndex] column.title = columnName @@ -430,7 +436,7 @@ struct DataGridView: NSViewRepresentable { // Restore saved column widths after rebuild (from user resize or persisted layout) if hasSavedLayout { for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { - guard let colIndex = Self.columnIndex(from: column.identifier), + guard let colIndex = Self.dataColumnIndex(from: column.identifier), colIndex < rowProvider.columns.count else { continue } let baseName = rowProvider.columns[colIndex] if let savedWidth = columnLayout.columnWidths[baseName] { @@ -452,7 +458,7 @@ struct DataGridView: NSViewRepresentable { if !coordinator.hasUserResizedColumns, !hasSavedLayout { var newWidths: [String: CGFloat] = [:] for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { - guard let colIndex = Self.columnIndex(from: column.identifier), + guard let colIndex = Self.dataColumnIndex(from: column.identifier), colIndex < rowProvider.columns.count else { continue } newWidths[rowProvider.columns[colIndex]] = column.width } @@ -481,7 +487,7 @@ struct DataGridView: NSViewRepresentable { var currentWidths: [String: CGFloat] = [:] var currentOrder: [String] = [] for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { - guard let colIndex = Self.columnIndex(from: column.identifier), + guard let colIndex = Self.dataColumnIndex(from: column.identifier), colIndex < rowProvider.columns.count else { continue } let baseName = rowProvider.columns[colIndex] currentWidths[baseName] = column.width @@ -518,7 +524,7 @@ struct DataGridView: NSViewRepresentable { } else if let firstSort = sortState.columns.first, firstSort.columnIndex >= 0 && firstSort.columnIndex < rowProvider.columns.count { // Sync with first sort column for NSTableView's built-in sort indicators - let key = "col_\(firstSort.columnIndex)" + let key = Self.columnIdentifier(for: firstSort.columnIndex).rawValue let ascending = firstSort.direction == .ascending let currentDescriptor = tableView.sortDescriptors.first if currentDescriptor?.key != key || currentDescriptor?.ascending != ascending { @@ -547,7 +553,7 @@ struct DataGridView: NSViewRepresentable { let fkColumnIndices = IndexSet( tableView.tableColumns.enumerated().compactMap { displayIndex, tableColumn in guard tableColumn.identifier.rawValue != "__rowNumber__", - let modelIndex = Self.columnIndex(from: tableColumn.identifier), + let modelIndex = Self.dataColumnIndex(from: tableColumn.identifier), modelIndex < rowProvider.columns.count else { return nil } let columnName = rowProvider.columns[modelIndex] return rowProvider.columnForeignKeys[columnName] != nil ? displayIndex : nil @@ -593,7 +599,7 @@ struct DataGridView: NSViewRepresentable { // Handle editingCell if let cell = editingCell { - let tableColumn = cell.column + 1 + let tableColumn = DataGridView.tableColumnIndex(for: cell.column) if cell.row < tableView.numberOfRows && tableColumn < tableView.numberOfColumns { tableView.scrollRowToVisible(cell.row) Task { @MainActor [weak tableView] in @@ -615,7 +621,7 @@ struct DataGridView: NSViewRepresentable { /// Apply hidden column state to the table view private func applyColumnVisibility(to tableView: NSTableView) { for column in tableView.tableColumns where column.identifier.rawValue != "__rowNumber__" { - guard let colIndex = Self.columnIndex(from: column.identifier), + guard let colIndex = Self.dataColumnIndex(from: column.identifier), colIndex < rowProvider.columns.count else { continue } let columnName = rowProvider.columns[colIndex] let shouldHide = configuration.hiddenColumns.contains(columnName) @@ -627,8 +633,19 @@ struct DataGridView: NSViewRepresentable { // MARK: - Column Layout Helpers - /// Extract column index from a stable identifier like "col_3" - static func columnIndex(from identifier: NSUserInterfaceItemIdentifier) -> Int? { + static func columnIdentifier(for dataIndex: Int) -> NSUserInterfaceItemIdentifier { + NSUserInterfaceItemIdentifier("col_\(dataIndex)") + } + + static func tableColumnIndex(for dataIndex: Int) -> Int { + dataIndex + 1 + } + + static func dataColumnIndex(for tableColumnIndex: Int) -> Int { + tableColumnIndex - 1 + } + + static func dataColumnIndex(from identifier: NSUserInterfaceItemIdentifier) -> Int? { let raw = identifier.rawValue guard raw.hasPrefix("col_") else { return nil } return Int(raw.dropFirst(4)) @@ -643,7 +660,7 @@ struct DataGridView: NSViewRepresentable { // Build name→column map for O(1) lookup var columnMap: [String: NSTableColumn] = [:] for col in dataColumns { - if let idx = columnIndex(from: col.identifier), idx < columns.count { + if let idx = dataColumnIndex(from: col.identifier), idx < columns.count { columnMap[columns[idx]] = col } } @@ -651,7 +668,7 @@ struct DataGridView: NSViewRepresentable { for (targetIndex, columnName) in order.enumerated() { guard let sourceColumn = columnMap[columnName], let currentIndex = tableView.tableColumns.firstIndex(of: sourceColumn) else { continue } - let targetTableIndex = targetIndex + 1 // +1 for row number column + let targetTableIndex = tableColumnIndex(for: targetIndex) if currentIndex != targetTableIndex && targetTableIndex < tableView.numberOfColumns { tableView.moveColumn(currentIndex, toColumn: targetTableIndex) } @@ -662,9 +679,8 @@ struct DataGridView: NSViewRepresentable { /// Update column header titles to show multi-sort priority indicators (e.g., "name 1▲", "age 2▼") private static func updateSortIndicators(tableView: NSTableView, sortState: SortState, columns: [String]) { - for column in tableView.tableColumns where column.identifier.rawValue.hasPrefix("col_") { - let idString = column.identifier.rawValue - guard let colIndex = Int(idString.dropFirst(4)), + for column in tableView.tableColumns { + guard let colIndex = dataColumnIndex(from: column.identifier), colIndex < columns.count else { continue } let baseName = columns[colIndex] diff --git a/TablePro/Views/Results/Extensions/DataGridView+Click.swift b/TablePro/Views/Results/Extensions/DataGridView+Click.swift index 2805706cf..df15970ec 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Click.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Click.swift @@ -16,7 +16,7 @@ extension TableViewCoordinator { let column = sender.clickedColumn guard row >= 0, column > 0 else { return } - let columnIndex = column - 1 + let columnIndex = DataGridView.dataColumnIndex(for: column) guard !changeManager.isRowDeleted(row) else { return } // Single click only selects the row. Chevron buttons handle dropdown/picker actions. @@ -29,7 +29,7 @@ extension TableViewCoordinator { let column = sender.clickedColumn guard row >= 0, column > 0 else { return } - let columnIndex = column - 1 + let columnIndex = DataGridView.dataColumnIndex(for: column) guard !changeManager.isRowDeleted(row) else { return } let immutable = databaseType.map { PluginManager.shared.immutableColumns(for: $0) } ?? [] @@ -94,7 +94,7 @@ extension TableViewCoordinator { current = view.superview } guard let tableView else { return } - let column = columnIndex + 1 + let column = DataGridView.tableColumnIndex(for: columnIndex) // Structure view: dropdown and type picker columns take priority if let dropdownCols = dropdownColumns, dropdownCols.contains(columnIndex) { diff --git a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift index 92d8bd54e..f776041f1 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift @@ -21,7 +21,7 @@ extension TableViewCoordinator { ) } - guard columnId.hasPrefix("col_"), let columnIndex = Int(columnId.dropFirst(4)) else { return nil } + guard let columnIndex = DataGridView.dataColumnIndex(from: column.identifier) else { return nil } guard row >= 0 && row < cachedRowCount, columnIndex >= 0 && columnIndex < cachedColumnCount else { @@ -32,7 +32,7 @@ extension TableViewCoordinator { let displayValue = rowProvider.displayValue(atRow: row, column: columnIndex) let state = visualState(for: row) - let tableColumnIndex = columnIndex + 1 + let tableColumnIndex = DataGridView.tableColumnIndex(for: columnIndex) let isFocused: Bool = { guard let keyTableView = tableView as? KeyHandlingTableView, keyTableView.focusedRow == row, diff --git a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift index d0962b1af..b5f3d0679 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift @@ -17,8 +17,7 @@ extension TableViewCoordinator { let immutable = databaseType.map { PluginManager.shared.immutableColumns(for: $0) } ?? [] if !immutable.isEmpty, - columnId.hasPrefix("col_"), - let columnIndex = Int(columnId.dropFirst(4)), + let columnIndex = DataGridView.dataColumnIndex(from: tableColumn.identifier), columnIndex < rowProvider.columns.count, immutable.contains(rowProvider.columns[columnIndex]) { return false @@ -26,8 +25,7 @@ extension TableViewCoordinator { // Popover-editor columns (date/FK/JSON) are only editable via // double-click (handleDoubleClick). Block inline editing for them. - if columnId.hasPrefix("col_"), - let columnIndex = Int(columnId.dropFirst(4)) { + if let columnIndex = DataGridView.dataColumnIndex(from: tableColumn.identifier) { if columnIndex < rowProvider.columns.count { let columnName = rowProvider.columns[columnIndex] if rowProvider.columnForeignKeys[columnName] != nil { return false } @@ -96,7 +94,7 @@ extension TableViewCoordinator { rowProvider.updateValue(newValue, at: row, columnIndex: columnIndex) delegate?.dataGridDidEditCell(row: row, column: columnIndex, newValue: newValue) - let tableColumnIndex = columnIndex + 1 + let tableColumnIndex = DataGridView.tableColumnIndex(for: columnIndex) tableView?.reloadData(forRowIndexes: IndexSet(integer: row), columnIndexes: IndexSet(integer: tableColumnIndex)) } @@ -147,7 +145,7 @@ extension TableViewCoordinator { guard row >= 0, column > 0 else { return true } - let columnIndex = column - 1 + let columnIndex = DataGridView.dataColumnIndex(for: column) if isEscapeCancelling { isEscapeCancelling = false diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index ddd531ba7..1c7af5545 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -14,8 +14,7 @@ extension TableViewCoordinator { guard let sortDescriptor = tableView.sortDescriptors.first, let key = sortDescriptor.key, - key.hasPrefix("col_"), - let columnIndex = Int(key.dropFirst(4)), + let columnIndex = DataGridView.dataColumnIndex(from: NSUserInterfaceItemIdentifier(key)), columnIndex >= 0 && columnIndex < rowProvider.columns.count else { return } @@ -31,7 +30,7 @@ extension TableViewCoordinator { guard column.identifier.rawValue != "__rowNumber__" else { return column.width } - guard let dataColumnIndex = DataGridView.columnIndex(from: column.identifier) else { + guard let dataColumnIndex = DataGridView.dataColumnIndex(from: column.identifier) else { return column.width } @@ -64,14 +63,14 @@ extension TableViewCoordinator { // Derive base column name from stable identifier (avoids sort indicator in title) let baseName: String = { - if let idx = DataGridView.columnIndex(from: column.identifier), + if let idx = DataGridView.dataColumnIndex(from: column.identifier), idx < rowProvider.columns.count { return rowProvider.columns[idx] } return column.title }() - if let dataColumnIndex = DataGridView.columnIndex(from: column.identifier) { + if let dataColumnIndex = DataGridView.dataColumnIndex(from: column.identifier) { let sortAscItem = NSMenuItem( title: String(localized: "Sort Ascending"), action: #selector(sortAscending(_:)), @@ -104,7 +103,7 @@ extension TableViewCoordinator { menu.addItem(filterItem) // "Display As" submenu for value display format overrides - if let dataColumnIndex = DataGridView.columnIndex(from: column.identifier) { + if let dataColumnIndex = DataGridView.dataColumnIndex(from: column.identifier) { let columnType = dataColumnIndex < rowProvider.columnTypes.count ? rowProvider.columnTypes[dataColumnIndex] : nil let applicableFormats = ValueDisplayFormat.applicableFormats(for: columnType) if applicableFormats.count > 1 { @@ -200,7 +199,7 @@ extension TableViewCoordinator { columnIndex >= 0 && columnIndex < tableView.tableColumns.count else { return } let column = tableView.tableColumns[columnIndex] - guard let dataColumnIndex = DataGridView.columnIndex(from: column.identifier) else { return } + guard let dataColumnIndex = DataGridView.dataColumnIndex(from: column.identifier) else { return } let width = cellFactory.calculateFitToContentWidth( for: dataColumnIndex < rowProvider.columns.count ? rowProvider.columns[dataColumnIndex] : column.title, @@ -216,7 +215,7 @@ extension TableViewCoordinator { for column in tableView.tableColumns { guard column.identifier.rawValue != "__rowNumber__", - let dataColumnIndex = DataGridView.columnIndex(from: column.identifier) else { continue } + let dataColumnIndex = DataGridView.dataColumnIndex(from: column.identifier) else { continue } let width = cellFactory.calculateFitToContentWidth( for: dataColumnIndex < rowProvider.columns.count ? rowProvider.columns[dataColumnIndex] : column.title, diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 40e934c9a..73f66b600 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -145,7 +145,7 @@ final class KeyHandlingTableView: NSTableView { case #selector(insertNewline(_:)): return selectedRow >= 0 && focusedColumn >= 1 && coordinator?.isEditable == true case #selector(cancelOperation(_:)): - return focusedRow >= 0 || focusedColumn >= 0 || !selectedRowIndexes.isEmpty + return focusedRow >= 0 || focusedColumn >= 0 default: return super.validateUserInterfaceItem(item) } @@ -209,6 +209,22 @@ final class KeyHandlingTableView: NSTableView { handleRightArrow(currentRow: row) return + case .home: + handleHome(isShiftHeld: isShiftHeld) + return + + case .end: + handleEnd(isShiftHeld: isShiftHeld) + return + + case .pageUp: + handlePageUp(isShiftHeld: isShiftHeld) + return + + case .pageDown: + handlePageDown(isShiftHeld: isShiftHeld) + return + default: break } @@ -239,7 +255,7 @@ final class KeyHandlingTableView: NSTableView { } // Multiline values use overlay editor instead of field editor - let columnIndex = focusedColumn - 1 + let columnIndex = DataGridView.dataColumnIndex(for: focusedColumn) if let value = coordinator?.rowProvider.value(atRow: row, column: columnIndex), value.containsLineBreak { coordinator?.showOverlayEditor(tableView: self, row: row, column: focusedColumn, columnIndex: columnIndex, value: value) @@ -256,11 +272,9 @@ final class KeyHandlingTableView: NSTableView { delete(sender) } - /// Handle ESC key - clear selection and focus @objc override func cancelOperation(_ sender: Any?) { focusedRow = -1 focusedColumn = -1 - deselectAll(sender) } // MARK: - Arrow Key and Tab Helpers @@ -388,6 +402,94 @@ final class KeyHandlingTableView: NSTableView { } } + private func handleHome(isShiftHeld: Bool) { + guard numberOfRows > 0 else { return } + if isShiftHeld { + if selectionAnchor == -1 { + selectionAnchor = selectedRow >= 0 ? selectedRow : 0 + selectionPivot = selectionAnchor + } + selectionPivot = 0 + let range = IndexSet(integersIn: 0...selectionAnchor) + selectRowIndexes(range, byExtendingSelection: false) + } else { + selectionAnchor = 0 + selectionPivot = 0 + focusedRow = 0 + selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) + } + scrollRowToVisible(0) + } + + private func handleEnd(isShiftHeld: Bool) { + guard numberOfRows > 0 else { return } + let lastRow = numberOfRows - 1 + if isShiftHeld { + if selectionAnchor == -1 { + selectionAnchor = selectedRow >= 0 ? selectedRow : lastRow + selectionPivot = selectionAnchor + } + selectionPivot = lastRow + let range = IndexSet(integersIn: selectionAnchor...lastRow) + selectRowIndexes(range, byExtendingSelection: false) + } else { + selectionAnchor = lastRow + selectionPivot = lastRow + focusedRow = lastRow + selectRowIndexes(IndexSet(integer: lastRow), byExtendingSelection: false) + } + scrollRowToVisible(lastRow) + } + + private func handlePageUp(isShiftHeld: Bool) { + guard numberOfRows > 0 else { return } + let visibleRows = max(1, Int(visibleRect.height / rowHeight) - 1) + let currentRow = selectedRow >= 0 ? selectedRow : 0 + let targetRow = max(0, currentRow - visibleRows) + + if isShiftHeld { + if selectionAnchor == -1 { + selectionAnchor = currentRow + selectionPivot = currentRow + } + selectionPivot = targetRow + let startRow = min(selectionAnchor, selectionPivot) + let endRow = max(selectionAnchor, selectionPivot) + selectRowIndexes(IndexSet(integersIn: startRow...endRow), byExtendingSelection: false) + } else { + selectionAnchor = targetRow + selectionPivot = targetRow + focusedRow = targetRow + selectRowIndexes(IndexSet(integer: targetRow), byExtendingSelection: false) + } + scrollRowToVisible(targetRow) + } + + private func handlePageDown(isShiftHeld: Bool) { + guard numberOfRows > 0 else { return } + let visibleRows = max(1, Int(visibleRect.height / rowHeight) - 1) + let currentRow = selectedRow >= 0 ? selectedRow : 0 + let lastRow = numberOfRows - 1 + let targetRow = min(lastRow, currentRow + visibleRows) + + if isShiftHeld { + if selectionAnchor == -1 { + selectionAnchor = currentRow + selectionPivot = currentRow + } + selectionPivot = targetRow + let startRow = min(selectionAnchor, selectionPivot) + let endRow = max(selectionAnchor, selectionPivot) + selectRowIndexes(IndexSet(integersIn: startRow...endRow), byExtendingSelection: false) + } else { + selectionAnchor = targetRow + selectionPivot = targetRow + focusedRow = targetRow + selectRowIndexes(IndexSet(integer: targetRow), byExtendingSelection: false) + } + scrollRowToVisible(targetRow) + } + override func menu(for event: NSEvent) -> NSMenu? { let point = convert(event.locationInWindow, from: nil) let clickedRow = row(at: point) diff --git a/TablePro/Views/Results/TableRowViewWithMenu.swift b/TablePro/Views/Results/TableRowViewWithMenu.swift index 26a544c39..c703a8c9c 100644 --- a/TablePro/Views/Results/TableRowViewWithMenu.swift +++ b/TablePro/Views/Results/TableRowViewWithMenu.swift @@ -23,7 +23,7 @@ final class TableRowViewWithMenu: NSTableRowView { let clickedColumn = tableView.column(at: locationInTable) // Adjust for row number column (index 0) - let dataColumnIndex = clickedColumn > 0 ? clickedColumn - 1 : -1 + let dataColumnIndex = clickedColumn > 0 ? DataGridView.dataColumnIndex(for: clickedColumn) : -1 let menu = NSMenu() @@ -36,8 +36,7 @@ final class TableRowViewWithMenu: NSTableRowView { if !coordinator.changeManager.isRowDeleted(rowIndex) { // Copy let copyItem = NSMenuItem( - title: String(localized: "Copy"), action: #selector(copySelectedOrCurrentRow), keyEquivalent: "c") - copyItem.keyEquivalentModifierMask = .command + title: String(localized: "Copy"), action: #selector(copySelectedOrCurrentRow), keyEquivalent: "") copyItem.target = self menu.addItem(copyItem) @@ -56,8 +55,7 @@ final class TableRowViewWithMenu: NSTableRowView { let copyWithHeadersItem = NSMenuItem( title: String(localized: "With Headers"), action: #selector(copySelectedOrCurrentRowWithHeaders), - keyEquivalent: "c") - copyWithHeadersItem.keyEquivalentModifierMask = [.command, .shift] + keyEquivalent: "") copyWithHeadersItem.target = self copyAsMenu.addItem(copyWithHeadersItem) @@ -95,8 +93,7 @@ final class TableRowViewWithMenu: NSTableRowView { // Paste if coordinator.isEditable { let pasteItem = NSMenuItem( - title: String(localized: "Paste"), action: #selector(pasteRows), keyEquivalent: "v") - pasteItem.keyEquivalentModifierMask = .command + title: String(localized: "Paste"), action: #selector(pasteRows), keyEquivalent: "") pasteItem.target = self menu.addItem(pasteItem) } @@ -174,17 +171,15 @@ final class TableRowViewWithMenu: NSTableRowView { // Duplicate & Delete if coordinator.isEditable { let duplicateItem = NSMenuItem( - title: String(localized: "Duplicate"), action: #selector(duplicateRow), keyEquivalent: "d") - duplicateItem.keyEquivalentModifierMask = .command + title: String(localized: "Duplicate"), action: #selector(duplicateRow), keyEquivalent: "") duplicateItem.target = self menu.addItem(duplicateItem) let deleteItem = NSMenuItem( title: String(localized: "Delete"), action: #selector(deleteRow), - keyEquivalent: String(UnicodeScalar(NSBackspaceCharacter).map { Character($0) } ?? "\u{8}") + keyEquivalent: "" ) - deleteItem.keyEquivalentModifierMask = [] deleteItem.target = self menu.addItem(deleteItem) } @@ -286,7 +281,7 @@ final class TableRowViewWithMenu: NSTableRowView { guard let columnIndex = sender.representedObject as? Int, let coordinator, let tableView = coordinator.tableView else { return } coordinator.showForeignKeyPreview( - tableView: tableView, row: rowIndex, column: columnIndex + 1, columnIndex: columnIndex + tableView: tableView, row: rowIndex, column: DataGridView.tableColumnIndex(for: columnIndex), columnIndex: columnIndex ) } diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index ee4400b55..fdddcbeed 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -91,10 +91,8 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut | Move between cells | Arrow keys | | Next cell | `Tab` | | Previous cell | `Shift+Tab` | -| First cell in row | `Home` | -| Last cell in row | `End` | -| First row | `Cmd+Home` | -| Last row | `Cmd+End` | +| First row | `Home` | +| Last row | `End` | | Page up | `Page Up` | | Page down | `Page Down` | @@ -128,6 +126,10 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut | Select multiple cells | Click + drag | | Extend selection | Shift + click | | Add to selection | Cmd + click | +| Extend selection by row | `Shift+Up` / `Shift+Down` | +| Select to first row | `Shift+Home` | +| Select to last row | `Shift+End` | +| Extend selection by page | `Shift+Page Up` / `Shift+Page Down` | | Select all | `Cmd+A` | ### Clipboard