diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b5acfbec..16f51fbae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Click a focused cell to start editing without a second click - Data grid focus ring follows the system accent color and contrast settings - Data grid cells expose accessibility row and column index ranges to VoiceOver on all dataset sizes +- Multi-cell paste: paste TSV data from the clipboard into the grid starting from the focused cell, grouped as a single undo action +- Shift+Tab navigates to the previous cell in the data grid +- Copy rows writes TSV, HTML table, and plain text to the clipboard for richer paste in spreadsheet apps +- Row drag adds TSV and HTML representations alongside the internal drag type ### Changed @@ -34,6 +38,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Row data lives in a per-coordinator RowDataStore keyed by tab.id rather than on QueryTab itself, so SwiftUI's @Observable tracking on tabManager.tabs no longer fires for row writes. - DataGridConfiguration is Equatable; DataGridIdentity covers tabType, tableName, and primaryKeyColumns so updateNSView short-circuits when nothing structural changed. DataTabGridDelegate properties are wired in onAppear / onChange instead of in the body. - Date picker popover font follows the data grid font setting +- Data grid undo/redo uses the window's UndoManager instead of a private instance, unifying Cmd+Z across editor and grid +- Right-click during cell editing shows the native text context menu instead of the row menu ### Fixed diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index 34e057b3a..2d3b38a81 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -101,18 +101,21 @@ final class DataChangeManager: ChangeManaging { return lo } - private let undoManager: UndoManager = { - let manager = UndoManager() - manager.levelsOfUndo = 100 - return manager - }() + var undoManagerProvider: (() -> UndoManager?)? + var onUndoApplied: ((UndoResult) -> Void)? private var lastUndoResult: UndoResult? // MARK: - Undo/Redo Properties - var canUndo: Bool { undoManager.canUndo } - var canRedo: Bool { undoManager.canRedo } + var canUndo: Bool { undoManagerProvider?()?.canUndo ?? false } + var canRedo: Bool { undoManagerProvider?()?.canRedo ?? false } + + private func registerUndo(actionName: String, _ handler: @escaping (DataChangeManager) -> Void) { + guard let undoManager = undoManagerProvider?() else { return } + undoManager.registerUndo(withTarget: self, handler: handler) + undoManager.setActionName(actionName) + } // MARK: - Helper Methods @@ -138,7 +141,7 @@ final class DataChangeManager: ChangeManaging { func clearChangesAndUndoHistory() { clearChanges() - undoManager.removeAllActions() + undoManagerProvider?()?.removeAllActions(withTarget: self) } func configureForTable( @@ -159,7 +162,7 @@ final class DataChangeManager: ChangeManaging { modifiedCells.removeAll() insertedRowData.removeAll() changedRowIndices.removeAll() - undoManager.removeAllActions() + undoManagerProvider?()?.removeAllActions(withTarget: self) changes.removeAll() hasChanges = false @@ -232,13 +235,12 @@ final class DataChangeManager: ChangeManaging { newValue: newValue )) } - undoManager.registerUndo(withTarget: self) { target in + registerUndo(actionName: String(localized: "Edit Cell")) { target in target.applyDataUndo(.cellEdit( rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, previousValue: oldValue, newValue: newValue, originalRow: nil )) } - undoManager.setActionName(String(localized: "Edit Cell")) changedRowIndices.insert(rowIndex) hasChanges = !changes.isEmpty reloadVersion += 1 @@ -287,13 +289,12 @@ final class DataChangeManager: ChangeManaging { changedRowIndices.insert(rowIndex) } - undoManager.registerUndo(withTarget: self) { target in + registerUndo(actionName: String(localized: "Edit Cell")) { target in target.applyDataUndo(.cellEdit( rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, previousValue: oldValue, newValue: newValue, originalRow: originalRow )) } - undoManager.setActionName(String(localized: "Edit Cell")) hasChanges = !changes.isEmpty reloadVersion += 1 } @@ -307,10 +308,9 @@ final class DataChangeManager: ChangeManaging { changeIndex[RowChangeKey(rowIndex: rowIndex, type: .delete)] = changes.count - 1 deletedRowIndices.insert(rowIndex) changedRowIndices.insert(rowIndex) - undoManager.registerUndo(withTarget: self) { target in + registerUndo(actionName: String(localized: "Delete Row")) { target in target.applyDataUndo(.rowDeletion(rowIndex: rowIndex, originalRow: originalRow)) } - undoManager.setActionName(String(localized: "Delete Row")) hasChanges = true reloadVersion += 1 } @@ -336,10 +336,9 @@ final class DataChangeManager: ChangeManaging { changedRowIndices.insert(rowIndex) batchData.append((rowIndex: rowIndex, originalRow: originalRow)) } - undoManager.registerUndo(withTarget: self) { target in + registerUndo(actionName: String(localized: "Delete Rows")) { target in target.applyDataUndo(.batchRowDeletion(rows: batchData)) } - undoManager.setActionName(String(localized: "Delete Rows")) hasChanges = true reloadVersion += 1 } @@ -351,10 +350,9 @@ final class DataChangeManager: ChangeManaging { changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] = changes.count - 1 insertedRowIndices.insert(rowIndex) changedRowIndices.insert(rowIndex) - undoManager.registerUndo(withTarget: self) { target in + registerUndo(actionName: String(localized: "Insert Row")) { target in target.applyDataUndo(.rowInsertion(rowIndex: rowIndex)) } - undoManager.setActionName(String(localized: "Insert Row")) hasChanges = true reloadVersion += 1 } @@ -476,10 +474,9 @@ final class DataChangeManager: ChangeManaging { insertedRowData.removeValue(forKey: rowIndex) } - undoManager.registerUndo(withTarget: self) { target in + registerUndo(actionName: String(localized: "Insert Rows")) { target in target.applyDataUndo(.batchRowInsertion(rowIndices: validRows, rowValues: rowValues)) } - undoManager.setActionName(String(localized: "Insert Rows")) let sortedDeleted = validRows.sorted() @@ -506,13 +503,12 @@ final class DataChangeManager: ChangeManaging { private func applyDataUndo(_ action: UndoAction) { switch action { case .cellEdit(let rowIndex, let columnIndex, let columnName, let previousValue, let newValue, let originalRow): - undoManager.registerUndo(withTarget: self) { target in + registerUndo(actionName: String(localized: "Edit Cell")) { target in target.applyDataUndo(.cellEdit( rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, previousValue: newValue, newValue: previousValue, originalRow: originalRow )) } - undoManager.setActionName(String(localized: "Edit Cell")) let matchedIndex = changeIndex[RowChangeKey(rowIndex: rowIndex, type: .update)] ?? changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] @@ -567,13 +563,12 @@ final class DataChangeManager: ChangeManaging { case .rowInsertion(let rowIndex): let savedValues = insertedRowData[rowIndex] - undoManager.registerUndo(withTarget: self) { [savedValues] target in + registerUndo(actionName: String(localized: "Insert Row")) { [savedValues] target in if let savedValues { target.insertedRowData[rowIndex] = savedValues } target.applyDataUndo(.rowInsertion(rowIndex: rowIndex)) } - undoManager.setActionName(String(localized: "Insert Row")) if insertedRowIndices.contains(rowIndex) { undoRowInsertion(rowIndex: rowIndex) @@ -606,10 +601,9 @@ final class DataChangeManager: ChangeManaging { } case .rowDeletion(let rowIndex, let originalRow): - undoManager.registerUndo(withTarget: self) { target in + registerUndo(actionName: String(localized: "Delete Row")) { target in target.applyDataUndo(.rowDeletion(rowIndex: rowIndex, originalRow: originalRow)) } - undoManager.setActionName(String(localized: "Delete Row")) if deletedRowIndices.contains(rowIndex) { undoRowDeletion(rowIndex: rowIndex) @@ -626,10 +620,9 @@ final class DataChangeManager: ChangeManaging { } case .batchRowDeletion(let rows): - undoManager.registerUndo(withTarget: self) { target in + registerUndo(actionName: String(localized: "Delete Rows")) { target in target.applyDataUndo(.batchRowDeletion(rows: rows)) } - undoManager.setActionName(String(localized: "Delete Rows")) let isUndo = rows.contains { deletedRowIndices.contains($0.rowIndex) } if isUndo { @@ -651,10 +644,9 @@ final class DataChangeManager: ChangeManaging { } case .batchRowInsertion(let rowIndices, let rowValues): - undoManager.registerUndo(withTarget: self) { target in + registerUndo(actionName: String(localized: "Insert Rows")) { target in target.applyDataUndo(.batchRowInsertion(rowIndices: rowIndices, rowValues: rowValues)) } - undoManager.setActionName(String(localized: "Insert Rows")) let firstInserted = rowIndices.first.map { insertedRowIndices.contains($0) } ?? false if firstInserted { @@ -701,9 +693,12 @@ final class DataChangeManager: ChangeManaging { hasChanges = !changes.isEmpty reloadVersion += 1 + + if let result = lastUndoResult { + onUndoApplied?(result) + } } - /// Re-apply a cell edit during redo without registering a duplicate undo private func recordCellChangeForRedo( rowIndex: Int, columnIndex: Int, @@ -784,16 +779,16 @@ final class DataChangeManager: ChangeManaging { // MARK: - Undo/Redo Public API func undoLastChange() -> UndoResult? { - guard undoManager.canUndo else { return nil } + guard let um = undoManagerProvider?(), um.canUndo else { return nil } lastUndoResult = nil - undoManager.undo() + um.undo() return lastUndoResult } func redoLastChange() -> UndoResult? { - guard undoManager.canRedo else { return nil } + guard let um = undoManagerProvider?(), um.canRedo else { return nil } lastUndoResult = nil - undoManager.redo() + um.redo() return lastUndoResult } diff --git a/TablePro/Core/Services/Infrastructure/ClipboardService.swift b/TablePro/Core/Services/Infrastructure/ClipboardService.swift index 1624b6eaf..71994137a 100644 --- a/TablePro/Core/Services/Infrastructure/ClipboardService.swift +++ b/TablePro/Core/Services/Infrastructure/ClipboardService.swift @@ -9,23 +9,16 @@ import AppKit import UniformTypeIdentifiers -/// Protocol for clipboard operations -/// Abstraction allows for mocking in tests protocol ClipboardProvider { - /// Read text content from clipboard - /// - Returns: Text string if available, nil otherwise func readText() -> String? - - /// Write text content to clipboard - /// - Parameter text: Text to write func writeText(_ text: String) - - /// Check if clipboard contains text data + func writeTabular(tsv: String, html: String) var hasText: Bool { get } } -/// Concrete implementation using NSPasteboard struct NSPasteboardClipboardProvider: ClipboardProvider { + private static let tsvType = NSPasteboard.PasteboardType("public.utf8-tab-separated-values-text") + func readText() -> String? { NSPasteboard.general.string(forType: .string) } @@ -37,12 +30,19 @@ struct NSPasteboardClipboardProvider: ClipboardProvider { pb.setString(text, forType: NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier)) } + func writeTabular(tsv: String, html: String) { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(tsv, forType: .string) + pb.setString(tsv, forType: Self.tsvType) + pb.setString(html, forType: .html) + } + var hasText: Bool { NSPasteboard.general.string(forType: .string) != nil } } -/// Shared clipboard service instance @MainActor enum ClipboardService { static var shared: ClipboardProvider = NSPasteboardClipboardProvider() diff --git a/TablePro/Core/Services/Infrastructure/HtmlTableEncoder.swift b/TablePro/Core/Services/Infrastructure/HtmlTableEncoder.swift new file mode 100644 index 000000000..923394035 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/HtmlTableEncoder.swift @@ -0,0 +1,36 @@ +// +// HtmlTableEncoder.swift +// TablePro +// + +import Foundation + +enum HtmlTableEncoder { + static func encode(rows: [[String]], headers: [String]? = nil) -> String { + var html = "" + if let headers { + html += "" + for header in headers { + html += "" + } + html += "" + } + for row in rows { + html += "" + for cell in row { + html += "" + } + html += "" + } + html += "
\(escape(header))
\(escape(cell))
" + return html + } + + static func escape(_ string: String) -> String { + string + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + } +} diff --git a/TablePro/Core/Services/Query/RowOperationsManager.swift b/TablePro/Core/Services/Query/RowOperationsManager.swift index 5283502cd..de8cac6f1 100644 --- a/TablePro/Core/Services/Query/RowOperationsManager.swift +++ b/TablePro/Core/Services/Query/RowOperationsManager.swift @@ -176,7 +176,7 @@ final class RowOperationsManager { return applyUndoResult(result, resultRows: &resultRows) } - private func applyUndoResult(_ result: UndoResult, resultRows: inout [[String?]]) -> Set? { + func applyUndoResult(_ result: UndoResult, resultRows: inout [[String?]]) -> Set? { switch result.action { case .cellEdit(let rowIndex, let columnIndex, _, let previousValue, _, _): if rowIndex < resultRows.count { diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 541774d95..ffb2a392e 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -29536,6 +29536,9 @@ } } } + }, + "Paste Cells" : { + }, "Paste your CREATE TABLE statement below:" : { "extractionState" : "stale", diff --git a/TablePro/Views/Main/Child/DataTabGridDelegate.swift b/TablePro/Views/Main/Child/DataTabGridDelegate.swift index 5db4a7248..1d53268ce 100644 --- a/TablePro/Views/Main/Child/DataTabGridDelegate.swift +++ b/TablePro/Views/Main/Child/DataTabGridDelegate.swift @@ -75,13 +75,9 @@ final class DataTabGridDelegate: DataGridViewDelegate { NotificationCenter.default.post(name: .exportQueryResults, object: nil) } - func dataGridUndo() { - coordinator?.undoLastChange() - } + func dataGridUndo() {} - func dataGridRedo() { - coordinator?.redoLastChange() - } + func dataGridRedo() {} func dataGridNavigateFK(value: String, fkInfo: ForeignKeyInfo) { coordinator?.navigateToFKReference(value: value, fkInfo: fkInfo) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index 2c20996aa..d1a2ad00d 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -101,37 +101,21 @@ extension MainContentCoordinator { dataTabDelegate?.dataGridDidRemoveRows(at: IndexSet(integer: rowIndex)) } - func undoLastChange() { + func handleUndoResult(_ result: UndoResult) { guard let tabIndex = tabManager.selectedTabIndex, tabIndex < tabManager.tabs.count else { return } - let tabId = tabManager.tabs[tabIndex].id - let buffer = rowDataStore.buffer(for: tabId) - if let adjustedSelection = rowOperationsManager.undoLastChange( - resultRows: &buffer.rows + let tab = tabManager.tabs[tabIndex] + let buffer = rowDataStore.buffer(for: tab.id) + if let adjustedSelection = rowOperationsManager.applyUndoResult( + result, resultRows: &buffer.rows ) { selectionState.indices = adjustedSelection } - tabManager.tabs[tabIndex].hasUserInteraction = true - querySortCache.removeValue(forKey: tabId) - dataTabDelegate?.dataGridDidReplaceAllRows() - } - - func redoLastChange() { - guard let tabIndex = tabManager.selectedTabIndex, - tabIndex < tabManager.tabs.count else { return } - - let tab = tabManager.tabs[tabIndex] - let buffer = rowDataStore.buffer(for: tab.id) - _ = rowOperationsManager.redoLastChange( - resultRows: &buffer.rows, - columns: buffer.columns - ) - tabManager.tabs[tabIndex].hasUserInteraction = true querySortCache.removeValue(forKey: tab.id) - dataTabDelegate?.dataGridDidReplaceAllRows() + dataTabDelegate?.tableViewCoordinator?.invalidateCachesForUndoRedo() } func copySelectedRowsToClipboard(indices: Set) { diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index db053689a..699b7adc4 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -719,7 +719,7 @@ final class MainContentCommandActions { if coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure { coordinator?.structureActions?.undo?() } else { - coordinator?.undoLastChange() + coordinator?.contentWindow?.undoManager?.undo() } } @@ -727,7 +727,7 @@ final class MainContentCommandActions { if coordinator?.tabManager.selectedTab?.display.resultsViewMode == .structure { coordinator?.structureActions?.redo?() } else { - coordinator?.redoLastChange() + coordinator?.contentWindow?.undoManager?.redo() } } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 6e50c2188..ee1dd418f 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -383,6 +383,8 @@ final class MainContentCoordinator { self.schemaProvider = SchemaProviderRegistry.shared.getOrCreate(for: connection.id) SchemaProviderRegistry.shared.retain(for: connection.id) urlFilterObservers = setupURLNotificationObservers() + changeManager.undoManagerProvider = { [weak self] in self?.contentWindow?.undoManager } + changeManager.onUndoApplied = { [weak self] result in self?.handleUndoResult(result) } // Synchronous save at quit time. NotificationCenter with queue: .main // delivers the closure on the main thread, satisfying assumeIsolated's diff --git a/TablePro/Views/Results/CellTextField.swift b/TablePro/Views/Results/CellTextField.swift index e296c99e0..71792d3e0 100644 --- a/TablePro/Views/Results/CellTextField.swift +++ b/TablePro/Views/Results/CellTextField.swift @@ -80,21 +80,4 @@ final class DataGridFieldEditor: NSTextView { } return super.performKeyEquivalent(with: event) } - - override func rightMouseDown(with event: NSEvent) { - window?.makeFirstResponder(nil) - - var view: NSView? = self - while let parent = view?.superview { - if let cellTextField = parent as? CellTextField { - cellTextField.rightMouseDown(with: event) - return - } - view = parent - } - } - - override func menu(for event: NSEvent) -> NSMenu? { - nil - } } diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 7100c6710..898748a5d 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -31,16 +31,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var primaryKeyColumn: String? { primaryKeyColumns.first } var tabType: TabType? - /// Check if undo is available - func canUndo() -> Bool { - changeManager.hasChanges - } - - /// Check if redo is available - func canRedo() -> Bool { - changeManager.canRedo - } - /// Capture current column widths and order from the live NSTableView /// and persist directly to ColumnLayoutStorage. Called from dismantleNSView /// to guarantee layout is saved even when the view is torn down without @@ -258,12 +248,19 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData func applyFullReplace() { guard let tableView else { return } + rowProvider.invalidateDisplayCache() rebuildVisualStateCache() updateCache() tableView.reloadData() lastIdentity = nil } + func invalidateCachesForUndoRedo() { + rowProvider.invalidateDisplayCache() + rebuildVisualStateCache() + updateCache() + } + func rebuildColumnMetadataCache() { var enumSet = Set() var fkSet = Set() diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index 78a71655b..78f836dc7 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -34,34 +34,38 @@ extension TableViewCoordinator { func copyRows(at indices: Set) { let sortedIndices = indices.sorted() let columnTypes = rowProvider.columnTypes - var lines: [String] = [] + var tsvRows: [String] = [] + var htmlRows: [[String]] = [] for index in sortedIndices { guard let values = rowProvider.rowValues(at: index) else { continue } - let line = formatRowForCopy(values: values, columnTypes: columnTypes) - lines.append(line) + let formatted = formatRowValues(values: values, columnTypes: columnTypes) + tsvRows.append(formatted.joined(separator: "\t")) + htmlRows.append(formatted) } - let text = lines.joined(separator: "\n") - ClipboardService.shared.writeText(text) + let tsv = tsvRows.joined(separator: "\n") + let html = HtmlTableEncoder.encode(rows: htmlRows) + ClipboardService.shared.writeTabular(tsv: tsv, html: html) } func copyRowsWithHeaders(at indices: Set) { let sortedIndices = indices.sorted() let columnTypes = rowProvider.columnTypes - var lines: [String] = [] - - // Add header row - lines.append(rowProvider.columns.joined(separator: "\t")) + let columns = rowProvider.columns + var tsvRows: [String] = [columns.joined(separator: "\t")] + var htmlRows: [[String]] = [] for index in sortedIndices { guard let values = rowProvider.rowValues(at: index) else { continue } - let line = formatRowForCopy(values: values, columnTypes: columnTypes) - lines.append(line) + let formatted = formatRowValues(values: values, columnTypes: columnTypes) + tsvRows.append(formatted.joined(separator: "\t")) + htmlRows.append(formatted) } - let text = lines.joined(separator: "\n") - ClipboardService.shared.writeText(text) + let tsv = tsvRows.joined(separator: "\n") + let html = HtmlTableEncoder.encode(rows: htmlRows, headers: columns) + ClipboardService.shared.writeTabular(tsv: tsv, html: html) } @MainActor @@ -136,12 +140,12 @@ extension TableViewCoordinator { ClipboardService.shared.writeText(converter.generateJson(rows: rows)) } - private func formatRowForCopy(values: [String?], columnTypes: [ColumnType]?) -> String { + private func formatRowValues(values: [String?], columnTypes: [ColumnType]?) -> [String] { values.enumerated().map { index, value in guard let value else { return "NULL" } let columnType = columnTypes.flatMap { $0.indices.contains(index) ? $0[index] : nil } return BlobFormattingService.shared.formatIfNeeded(value, columnType: columnType, for: .copy) - }.joined(separator: "\t") + } } private func resolveDriver() -> (any DatabaseDriver)? { @@ -157,6 +161,16 @@ extension TableViewCoordinator { guard delegate != nil else { return nil } let item = NSPasteboardItem() item.setString(String(row), forType: Self.rowDragType) + + if let values = rowProvider.rowValues(at: row) { + let formatted = formatRowValues(values: values, columnTypes: rowProvider.columnTypes) + item.setString(formatted.joined(separator: "\t"), forType: .string) + item.setString( + HtmlTableEncoder.encode(rows: [formatted], headers: rowProvider.columns), + forType: .html + ) + } + return item } diff --git a/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift b/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift new file mode 100644 index 000000000..9ad521b8a --- /dev/null +++ b/TablePro/Views/Results/Extensions/DataGridView+CellPaste.swift @@ -0,0 +1,43 @@ +// +// DataGridView+CellPaste.swift +// TablePro +// + +import AppKit + +extension TableViewCoordinator { + func pasteCellsFromClipboard(anchorRow: Int, anchorColumn: Int) -> Bool { + guard isEditable else { return false } + guard let text = ClipboardService.shared.readText(), !text.isEmpty else { return false } + + let grid = text.components(separatedBy: "\n") + .filter { !$0.isEmpty } + .map { $0.components(separatedBy: "\t") } + guard !grid.isEmpty, grid[0].count > 1 || grid.count > 1 else { return false } + + let maxRow = min(anchorRow + grid.count, cachedRowCount) + let maxCol = min(anchorColumn + (grid.first?.count ?? 0), rowProvider.columns.count) + guard anchorRow < maxRow, anchorColumn < maxCol else { return false } + + let undoManager = tableView?.window?.undoManager + undoManager?.beginUndoGrouping() + undoManager?.setActionName(String(localized: "Paste Cells")) + + for (gridRow, rowValues) in grid.enumerated() { + let targetRow = anchorRow + gridRow + guard targetRow < maxRow else { break } + guard !changeManager.isRowDeleted(targetRow) else { continue } + + for (gridCol, cellValue) in rowValues.enumerated() { + let targetCol = anchorColumn + gridCol + guard targetCol < maxCol else { break } + commitCellEdit(row: targetRow, columnIndex: targetCol, newValue: cellValue) + } + } + + undoManager?.endUndoGrouping() + + tableView?.reloadData() + return true + } +} diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 78d2e7703..c1f62d60d 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -118,25 +118,17 @@ final class KeyHandlingTableView: NSTableView { coordinator?.delegate?.dataGridCopyRows(Set(selectedRowIndexes)) } - /// Paste rows from clipboard @objc func paste(_ sender: Any?) { guard coordinator?.isEditable == true else { return } + if focusedRow >= 0, focusedColumn >= 1 { + let dataCol = DataGridView.dataColumnIndex(for: focusedColumn) + if coordinator?.pasteCellsFromClipboard(anchorRow: focusedRow, anchorColumn: dataCol) == true { + return + } + } coordinator?.delegate?.dataGridPasteRows() } - /// Undo last change - @objc func undo(_ sender: Any?) { - guard coordinator?.isEditable == true else { return } - coordinator?.delegate?.dataGridUndo() - } - - /// Redo last undone change - @objc func redo(_ sender: Any?) { - guard coordinator?.isEditable == true else { return } - coordinator?.delegate?.dataGridRedo() - } - - /// Validate menu items and shortcuts override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { switch item.action { case #selector(delete(_:)), #selector(deleteBackward(_:)): @@ -145,14 +137,10 @@ final class KeyHandlingTableView: NSTableView { return !selectedRowIndexes.isEmpty case #selector(paste(_:)): return coordinator?.isEditable == true && coordinator?.delegate != nil - case #selector(undo(_:)): - return coordinator?.isEditable == true && (coordinator?.canUndo() ?? false) - case #selector(redo(_:)): - return coordinator?.isEditable == true && (coordinator?.canRedo() ?? false) case #selector(insertNewline(_:)): return selectedRow >= 0 && focusedColumn >= 1 && coordinator?.isEditable == true case #selector(cancelOperation(_:)): - return focusedRow >= 0 || focusedColumn >= 0 + return false default: return super.validateUserInterfaceItem(item) } @@ -170,16 +158,18 @@ final class KeyHandlingTableView: NSTableView { // Handle Tab manually (NSTableView cell navigation requires custom logic) if key == .tab { - handleTabKey() + if event.modifierFlags.contains(.shift) { + handleShiftTabKey() + } else { + handleTabKey() + } return } - // Handle arrow keys (custom Shift+selection logic) let row = selectedRow let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) let isShiftHeld = modifiers.contains(.shift) - // Ctrl+HJKL navigation (arrow key alternatives for keyboards without dedicated arrows) if modifiers.contains(.control) { switch key { case .h: @@ -280,8 +270,6 @@ final class KeyHandlingTableView: NSTableView { } @objc override func cancelOperation(_ sender: Any?) { - focusedRow = -1 - focusedColumn = -1 } // MARK: - Arrow Key and Tab Helpers @@ -308,7 +296,6 @@ final class KeyHandlingTableView: NSTableView { } } - /// Handle Tab key - navigate to next cell (manual implementation required for NSTableView) private func handleTabKey() { let row = selectedRow guard row >= 0, focusedColumn >= 1 else { return } @@ -332,6 +319,29 @@ final class KeyHandlingTableView: NSTableView { scrollColumnToVisible(nextColumn) } + private func handleShiftTabKey() { + let row = selectedRow + guard row >= 0, focusedColumn >= 1 else { return } + + var prevColumn = focusedColumn - 1 + var prevRow = row + + if prevColumn < 1 { + prevColumn = numberOfColumns - 1 + prevRow -= 1 + } + if prevRow < 0 { + prevRow = 0 + prevColumn = 1 + } + + selectRowIndexes(IndexSet(integer: prevRow), byExtendingSelection: false) + focusedRow = prevRow + focusedColumn = prevColumn + scrollRowToVisible(prevRow) + scrollColumnToVisible(prevColumn) + } + // MARK: - Arrow Key Selection Helpers private func handleUpArrow(currentRow: Int, isShiftHeld: Bool) { diff --git a/TablePro/Views/Results/RowDeltaApplying.swift b/TablePro/Views/Results/RowDeltaApplying.swift index 2b3a225d8..affc103e3 100644 --- a/TablePro/Views/Results/RowDeltaApplying.swift +++ b/TablePro/Views/Results/RowDeltaApplying.swift @@ -5,6 +5,7 @@ protocol RowDeltaApplying: AnyObject { func applyInsertedRows(_ indices: IndexSet) func applyRemovedRows(_ indices: IndexSet) func applyFullReplace() + func invalidateCachesForUndoRedo() } extension TableViewCoordinator: RowDeltaApplying {} diff --git a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift index 1acfcb9b0..df4b27d80 100644 --- a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift @@ -20,6 +20,10 @@ private final class MockClipboardProvider: ClipboardProvider { lastWrittenText = text } + func writeTabular(tsv: String, html: String) { + lastWrittenText = tsv + } + var hasText: Bool { textToRead != nil } }