diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index 5587136a8..47e392a7d 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -369,22 +369,6 @@ final class DataChangeManager: ChangeManaging { } } - // MARK: - Undo/Redo Public API - - func undoLastChange() -> UndoResult? { - guard let um = undoManagerProvider?(), um.canUndo else { return nil } - lastUndoResult = nil - um.undo() - return lastUndoResult - } - - func redoLastChange() -> UndoResult? { - guard let um = undoManagerProvider?(), um.canRedo else { return nil } - lastUndoResult = nil - um.redo() - return lastUndoResult - } - // MARK: - SQL Generation func generateSQL() throws -> [ParameterizedStatement] { diff --git a/TablePro/Core/Services/Query/RowOperationsManager.swift b/TablePro/Core/Services/Query/RowOperationsManager.swift index dde885422..eb909fc2a 100644 --- a/TablePro/Core/Services/Query/RowOperationsManager.swift +++ b/TablePro/Core/Services/Query/RowOperationsManager.swift @@ -150,16 +150,6 @@ final class RowOperationsManager { ) } - func undoLastChange(tableRows: inout TableRows) -> UndoApplicationResult? { - guard let result = changeManager.undoLastChange() else { return nil } - return applyUndoResult(result, tableRows: &tableRows) - } - - func redoLastChange(tableRows: inout TableRows) -> UndoApplicationResult? { - guard let result = changeManager.redoLastChange() else { return nil } - return applyUndoResult(result, tableRows: &tableRows) - } - func applyUndoResult(_ result: UndoResult, tableRows: inout TableRows) -> UndoApplicationResult { switch result.action { case .cellEdit(let rowIndex, let columnIndex, _, let previousValue, _, _): diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 07628c4c0..17d2330b3 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -7740,6 +7740,9 @@ }, "Choose a certificate or key file" : { + }, + "Choose a fetched model" : { + }, "Choose a folder to watch for .tablepro connection files" : { "localizations" : { @@ -24879,6 +24882,9 @@ } } } + }, + "Model name" : { + }, "Model not found: %@" : { "localizations" : { @@ -31137,6 +31143,9 @@ } } } + }, + "Priority %d" : { + }, "Privacy" : { "localizations" : { @@ -35645,9 +35654,6 @@ } } } - }, - "Select a model" : { - }, "Select a Plugin" : { "localizations" : { @@ -37490,6 +37496,12 @@ } } } + }, + "Sorted ascending" : { + + }, + "Sorted descending" : { + }, "Sorting will reload data and discard all unsaved changes." : { "localizations" : { diff --git a/TablePro/Views/Results/Cells/CellFocusOverlay.swift b/TablePro/Views/Results/Cells/CellFocusOverlay.swift new file mode 100644 index 000000000..ed8ecda9f --- /dev/null +++ b/TablePro/Views/Results/Cells/CellFocusOverlay.swift @@ -0,0 +1,50 @@ +// +// CellFocusOverlay.swift +// TablePro +// + +import AppKit + +final class CellFocusOverlay: NSView { + enum Style { + case hidden + case contrastingBorder + } + + var style: Style = .hidden { + didSet { + guard oldValue != style else { return } + applyStyle() + } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + translatesAutoresizingMaskIntoConstraints = false + isHidden = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + if !isHidden { applyStyle() } + } + + private func applyStyle() { + switch style { + case .hidden: + isHidden = true + layer?.borderWidth = 0 + case .contrastingBorder: + isHidden = false + layer?.borderWidth = 2 + layer?.borderColor = NSColor.alternateSelectedControlTextColor.cgColor + } + } +} diff --git a/TablePro/Views/Results/Cells/DataGridBaseCellView.swift b/TablePro/Views/Results/Cells/DataGridBaseCellView.swift index 3f5225b6f..369e88dee 100644 --- a/TablePro/Views/Results/Cells/DataGridBaseCellView.swift +++ b/TablePro/Views/Results/Cells/DataGridBaseCellView.swift @@ -34,10 +34,22 @@ class DataGridBaseCellView: NSTableCellView { var isFocusedCell: Bool = false { didSet { guard oldValue != isFocusedCell else { return } - updateFocusRing() + updateFocusPresentation() } } + private lazy var focusOverlay: CellFocusOverlay = { + let overlay = CellFocusOverlay() + addSubview(overlay) + NSLayoutConstraint.activate([ + overlay.leadingAnchor.constraint(equalTo: leadingAnchor), + overlay.trailingAnchor.constraint(equalTo: trailingAnchor), + overlay.topAnchor.constraint(equalTo: topAnchor), + overlay.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + return overlay + }() + private(set) lazy var backgroundView: NSView = { let view = NSView() view.wantsLayer = true @@ -189,35 +201,23 @@ class DataGridBaseCellView: NSTableCellView { override var backgroundStyle: NSView.BackgroundStyle { didSet { backgroundView.isHidden = (backgroundStyle == .emphasized) || (changeBackgroundColor == nil) - if isFocusedCell { updateFocusRing() } + updateFocusPresentation() } } - override var focusRingMaskBounds: NSRect { bounds } + override var focusRingMaskBounds: NSRect { + backgroundStyle == .emphasized ? .zero : bounds + } override func drawFocusRingMask() { + guard backgroundStyle != .emphasized else { return } NSBezierPath(rect: bounds).fill() } - override func draw(_ dirtyRect: NSRect) { - super.draw(dirtyRect) - guard isFocusedCell, backgroundStyle != .emphasized else { return } - NSGraphicsContext.saveGraphicsState() - NSFocusRingPlacement.only.set() - drawFocusRingMask() - NSGraphicsContext.restoreGraphicsState() - } - - override func viewDidChangeEffectiveAppearance() { - super.viewDidChangeEffectiveAppearance() - if isFocusedCell { - needsDisplay = true - } - } - - private func updateFocusRing() { - focusRingType = isFocusedCell ? .exterior : .none + private func updateFocusPresentation() { + let onEmphasized = backgroundStyle == .emphasized + focusOverlay.style = (isFocusedCell && onEmphasized) ? .contrastingBorder : .hidden + focusRingType = (isFocusedCell && !onEmphasized) ? .exterior : .none noteFocusRingMaskChanged() - needsDisplay = true } } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift index 9392f8601..d8537dc49 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift @@ -30,19 +30,60 @@ extension TableViewCoordinator { guard !isSyncingSelection else { return } guard let tableView = notification.object as? NSTableView else { return } + let previousSelection = selectedRowIndices let newSelection = Set(tableView.selectedRowIndexes.map { $0 }) - if newSelection != selectedRowIndices { + if newSelection != previousSelection { selectedRowIndices = newSelection } - if let keyTableView = tableView as? KeyHandlingTableView { - if newSelection.isEmpty { - keyTableView.focusedRow = -1 - keyTableView.focusedColumn = -1 - } else if keyTableView.focusedRow < 0, let firstRow = newSelection.min() { - keyTableView.focusedRow = firstRow - keyTableView.focusedColumn = 1 - } + guard let keyTableView = tableView as? KeyHandlingTableView else { return } + + let newFocus = resolvedFocus( + previous: previousSelection, + current: newSelection, + existingFocusedRow: keyTableView.focusedRow, + existingFocusedColumn: keyTableView.focusedColumn, + tableView: tableView + ) + + if keyTableView.focusedRow != newFocus.row { + keyTableView.focusedRow = newFocus.row + } + if keyTableView.focusedColumn != newFocus.column { + keyTableView.focusedColumn = newFocus.column + } + } + + private func resolvedFocus( + previous: Set, + current: Set, + existingFocusedRow: Int, + existingFocusedColumn: Int, + tableView: NSTableView + ) -> (row: Int, column: Int) { + if current.isEmpty { + return (-1, -1) + } + + let column = existingFocusedColumn >= 1 ? existingFocusedColumn : 1 + let added = current.subtracting(previous) + + if let tip = added.max() { + return (tip, column) + } + + let removed = previous.subtracting(current) + if let lostTip = removed.max(), + let currentMax = current.max(), + let currentMin = current.min() { + let row = lostTip > currentMax ? currentMax : currentMin + return (row, column) } + + if existingFocusedRow >= 0, current.contains(existingFocusedRow) { + return (existingFocusedRow, column) + } + + return (current.min() ?? -1, column) } } diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 1eb8e59e7..d4bffcb87 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -1,24 +1,8 @@ -// -// KeyHandlingTableView.swift -// TablePro -// -// NSTableView subclass that handles keyboard shortcuts and TablePlus-style cell focus. -// Uses Apple's responder chain pattern with interpretKeyEvents for standard shortcuts. -// -// Architecture: -// - Keyboard events → interpretKeyEvents → Standard selectors (@objc moveUp, delete, etc.) -// - Uses KeyCode enum for readability (no magic numbers) -// - Responder chain validation via validateUserInterfaceItem -// - import AppKit -/// NSTableView subclass that handles keyboard shortcuts and TablePlus-style cell focus on click final class KeyHandlingTableView: NSTableView { weak var coordinator: TableViewCoordinator? - // MARK: - First Responder - override var acceptsFirstResponder: Bool { true } @@ -26,13 +10,37 @@ final class KeyHandlingTableView: NSTableView { var selection = TableSelection() { didSet { guard let (rows, columns) = selection.reloadIndexes(from: oldValue) else { return } - let validRows = rows.filteredIndexSet { $0 < numberOfRows } - let validColumns = columns.filteredIndexSet { $0 < numberOfColumns } - guard !validRows.isEmpty, !validColumns.isEmpty else { return } - reloadData(forRowIndexes: validRows, columnIndexes: validColumns) + scheduleFocusReload(rows: rows, columns: columns) + } + } + + private var pendingFocusReloadRows: IndexSet? + private var pendingFocusReloadColumns: IndexSet? + + private func scheduleFocusReload(rows: IndexSet, columns: IndexSet) { + if pendingFocusReloadRows != nil { + pendingFocusReloadRows?.formUnion(rows) + pendingFocusReloadColumns?.formUnion(columns) + return + } + pendingFocusReloadRows = rows + pendingFocusReloadColumns = columns + DispatchQueue.main.async { [weak self] in + self?.flushPendingFocusReload() } } + private func flushPendingFocusReload() { + guard let pendingRows = pendingFocusReloadRows, + let pendingColumns = pendingFocusReloadColumns else { return } + pendingFocusReloadRows = nil + pendingFocusReloadColumns = nil + let validRows = pendingRows.filteredIndexSet { $0 < numberOfRows } + let validColumns = pendingColumns.filteredIndexSet { $0 < numberOfColumns } + guard !validRows.isEmpty, !validColumns.isEmpty else { return } + reloadData(forRowIndexes: validRows, columnIndexes: validColumns) + } + var focusedRow: Int { get { selection.focusedRow } set { selection.focusedRow = newValue } @@ -43,18 +51,6 @@ final class KeyHandlingTableView: NSTableView { set { selection.focusedColumn = newValue } } - var selectionAnchor: Int { - get { selection.anchor } - set { selection.anchor = newValue } - } - - var selectionPivot: Int { - get { selection.pivot } - set { selection.pivot = newValue } - } - - // MARK: - TablePlus-Style Cell Focus - override func mouseDown(with event: NSEvent) { window?.makeFirstResponder(self) @@ -67,11 +63,6 @@ final class KeyHandlingTableView: NSTableView { return } - if clickedRow >= 0 && !event.modifierFlags.contains(.shift) { - selectionAnchor = clickedRow - selectionPivot = clickedRow - } - let alreadyFocusedHere = clickedRow >= 0 && clickedColumn >= 0 && clickedRow == focusedRow @@ -103,8 +94,6 @@ final class KeyHandlingTableView: NSTableView { } } - // MARK: - Standard Edit Menu Actions - @objc func delete(_ sender: Any?) { guard coordinator?.isEditable == true else { return } guard !selectedRowIndexes.isEmpty else { return } @@ -143,17 +132,12 @@ final class KeyHandlingTableView: NSTableView { } } - // MARK: - Keyboard Handling - - /// Convert key events to standard selectors using interpretKeyEvents - /// This enables proper responder chain behavior and accessibility support override func keyDown(with event: NSEvent) { guard let key = KeyCode(rawValue: event.keyCode) else { super.keyDown(with: event) return } - // Handle Tab manually (NSTableView cell navigation requires custom logic) if key == .tab { if event.modifierFlags.contains(.shift) { handleShiftTabKey() @@ -165,36 +149,8 @@ final class KeyHandlingTableView: NSTableView { let row = selectedRow let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - let isShiftHeld = modifiers.contains(.shift) - - if modifiers.contains(.control) { - switch key { - case .h: - handleLeftArrow(currentRow: row) - return - case .j: - handleDownArrow(currentRow: row, isShiftHeld: isShiftHeld) - return - case .k: - handleUpArrow(currentRow: row, isShiftHeld: isShiftHeld) - return - case .l: - handleRightArrow(currentRow: row) - return - default: - break - } - } switch key { - case .upArrow: - handleUpArrow(currentRow: row, isShiftHeld: isShiftHeld) - return - - case .downArrow: - handleDownArrow(currentRow: row, isShiftHeld: isShiftHeld) - return - case .leftArrow: handleLeftArrow(currentRow: row) return @@ -203,27 +159,20 @@ 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) + case .upArrow, .downArrow, .home, .end, .pageUp, .pageDown: + super.keyDown(with: event) return - case .pageDown: - handlePageDown(isShiftHeld: isShiftHeld) - return + case .delete, .forwardDelete: + if modifiers.isEmpty || modifiers == .command { + deleteSelectedRowsIfPossible() + return + } default: break } - // FK preview: dispatch from user-configurable shortcut (default: Space) if let fkCombo = AppSettingsManager.shared.keyboard.shortcut(for: .previewFKReference), !fkCombo.isCleared, fkCombo.matches(event), @@ -234,14 +183,9 @@ final class KeyHandlingTableView: NSTableView { return } - // For all other keys, use interpretKeyEvents to map to standard selectors - // This handles Return → insertNewline(_:), Delete → deleteBackward(_:), ESC → cancelOperation(_:) interpretKeyEvents([event]) } - // MARK: - Standard Responder Selectors - - /// Handle Return/Enter key - start editing current cell @objc override func insertNewline(_ sender: Any?) { let row = selectedRow guard row >= 0, focusedColumn >= 1, coordinator?.isEditable == true else { @@ -258,38 +202,67 @@ final class KeyHandlingTableView: NSTableView { editColumn(focusedColumn, row: row, with: nil, select: true) } - /// Handle Delete/Backspace key - delete selected rows - @objc override func deleteBackward(_ sender: Any?) { + @objc override func cancelOperation(_ sender: Any?) { + } + + private func deleteSelectedRowsIfPossible() { guard coordinator?.isEditable == true else { return } guard !selectedRowIndexes.isEmpty else { return } - delete(sender) + delete(nil) } - @objc override func cancelOperation(_ sender: Any?) { + private func handleLeftArrow(currentRow: Int) { + let target = focusedColumn < 0 + ? lastVisibleDataColumn() + : previousVisibleDataColumn(before: focusedColumn) + guard target >= 1 else { return } + focusedColumn = target + if currentRow >= 0 { scrollColumnToVisible(target) } + } + + private func handleRightArrow(currentRow: Int) { + let target = focusedColumn < 1 + ? firstVisibleDataColumn() + : nextVisibleDataColumn(after: focusedColumn) + guard target >= 1 else { return } + focusedColumn = target + if currentRow >= 0 { scrollColumnToVisible(target) } } - // MARK: - Arrow Key and Tab Helpers + private func firstVisibleDataColumn() -> Int { + for index in 1.. 1 { - focusedColumn -= 1 - if currentRow >= 0 { scrollColumnToVisible(focusedColumn) } - } else if focusedColumn == -1 && numberOfColumns > 1 { - focusedColumn = numberOfColumns - 1 - if currentRow >= 0 { scrollColumnToVisible(focusedColumn) } + private func lastVisibleDataColumn() -> Int { + for index in stride(from: numberOfColumns - 1, through: 1, by: -1) where isVisibleDataColumn(at: index) { + return index } + return -1 } - /// Handle right arrow key - move focus to next column - private func handleRightArrow(currentRow: Int) { - if focusedColumn >= 1 && focusedColumn < numberOfColumns - 1 { - focusedColumn += 1 - if currentRow >= 0 { scrollColumnToVisible(focusedColumn) } - } else if focusedColumn == -1 && numberOfColumns > 1 { - focusedColumn = 1 - if currentRow >= 0 { scrollColumnToVisible(focusedColumn) } + private func nextVisibleDataColumn(after current: Int) -> Int { + guard current + 1 < numberOfColumns else { return -1 } + for index in (current + 1).. Int { + guard current > 1 else { return -1 } + for index in stride(from: current - 1, through: 1, by: -1) where isVisibleDataColumn(at: index) { + return index } + return -1 + } + + private func isVisibleDataColumn(at index: Int) -> Bool { + guard index >= 0, index < numberOfColumns else { return false } + let column = tableColumns[index] + return !column.isHidden && column.identifier != ColumnIdentitySchema.rowNumberIdentifier } private func handleTabKey() { @@ -338,171 +311,6 @@ final class KeyHandlingTableView: NSTableView { scrollColumnToVisible(prevColumn) } - // MARK: - Arrow Key Selection Helpers - - private func handleUpArrow(currentRow: Int, isShiftHeld: Bool) { - guard numberOfRows > 0 else { return } - - if currentRow == -1 { - let targetRow = numberOfRows - 1 - selectionAnchor = targetRow - selectionPivot = targetRow - focusedRow = targetRow - selectRowIndexes(IndexSet(integer: targetRow), byExtendingSelection: false) - scrollRowToVisible(targetRow) - return - } - - if isShiftHeld { - if selectionAnchor == -1 { - selectionAnchor = currentRow - selectionPivot = currentRow - } - - let currentPivot = selectionPivot >= 0 ? selectionPivot : currentRow - let targetRow = max(0, currentPivot - 1) - selectionPivot = targetRow - - let startRow = min(selectionAnchor, selectionPivot) - let endRow = max(selectionAnchor, selectionPivot) - let range = IndexSet(integersIn: startRow...endRow) - selectRowIndexes(range, byExtendingSelection: false) - scrollRowToVisible(targetRow) - } else { - let targetRow = max(0, currentRow - 1) - selectionAnchor = targetRow - selectionPivot = targetRow - focusedRow = targetRow - selectRowIndexes(IndexSet(integer: targetRow), byExtendingSelection: false) - scrollRowToVisible(targetRow) - } - } - - private func handleDownArrow(currentRow: Int, isShiftHeld: Bool) { - guard numberOfRows > 0 else { return } - - if currentRow == -1 { - selectionAnchor = 0 - selectionPivot = 0 - focusedRow = 0 - selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) - scrollRowToVisible(0) - return - } - - if isShiftHeld { - if selectionAnchor == -1 { - selectionAnchor = currentRow - selectionPivot = currentRow - } - - let currentPivot = selectionPivot >= 0 ? selectionPivot : currentRow - let targetRow = min(numberOfRows - 1, currentPivot + 1) - selectionPivot = targetRow - - let startRow = min(selectionAnchor, selectionPivot) - let endRow = max(selectionAnchor, selectionPivot) - let range = IndexSet(integersIn: startRow...endRow) - selectRowIndexes(range, byExtendingSelection: false) - scrollRowToVisible(targetRow) - } else { - let targetRow = min(numberOfRows - 1, currentRow + 1) - selectionAnchor = targetRow - selectionPivot = targetRow - focusedRow = targetRow - selectRowIndexes(IndexSet(integer: targetRow), byExtendingSelection: false) - scrollRowToVisible(targetRow) - } - } - - 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) @@ -515,7 +323,6 @@ final class KeyHandlingTableView: NSTableView { return rowView.menu(for: event) } - // Empty space: ask delegate for a fallback menu (e.g., Structure tab "Add" actions) if let menu = coordinator?.delegate?.dataGridEmptySpaceMenu() { return menu } diff --git a/TablePro/Views/Results/SortableHeaderCell.swift b/TablePro/Views/Results/SortableHeaderCell.swift index 33d6ee8e2..41d20bf39 100644 --- a/TablePro/Views/Results/SortableHeaderCell.swift +++ b/TablePro/Views/Results/SortableHeaderCell.swift @@ -13,13 +13,20 @@ final class SortableHeaderCell: NSTableHeaderCell { private static let indicatorPadding: CGFloat = 4 private static let indicatorSpacing: CGFloat = 2 private static let priorityFontSize: CGFloat = 9 + private static let titleHorizontalPadding: CGFloat = 4 override init(textCell string: String) { super.init(textCell: string) + lineBreakMode = .byTruncatingTail + truncatesLastVisibleLine = true + wraps = false } required init(coder: NSCoder) { super.init(coder: coder) + lineBreakMode = .byTruncatingTail + truncatesLastVisibleLine = true + wraps = false } override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) { @@ -42,7 +49,7 @@ final class SortableHeaderCell: NSTableHeaderCell { width: max(0, cellFrame.width - reservedWidth), height: cellFrame.height ) - super.drawInterior(withFrame: titleFrame, in: controlView) + drawSortedTitle(in: titleFrame) let indicatorOriginX = cellFrame.maxX - Self.indicatorPadding - indicatorSize.width let indicatorOriginY = cellFrame.midY - indicatorSize.height / 2 @@ -66,6 +73,31 @@ final class SortableHeaderCell: NSTableHeaderCell { } } + private func drawSortedTitle(in rect: NSRect) { + let baseFont = font ?? NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) + let boldFont = NSFontManager.shared.convert(baseFont, toHaveTrait: .boldFontMask) + + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = alignment + paragraph.lineBreakMode = .byTruncatingTail + + let attributes: [NSAttributedString.Key: Any] = [ + .font: boldFont, + .foregroundColor: NSColor.headerTextColor, + .paragraphStyle: paragraph + ] + + let title = NSAttributedString(string: stringValue, attributes: attributes) + let textHeight = title.size().height + let drawRect = NSRect( + x: rect.minX + Self.titleHorizontalPadding, + y: rect.midY - textHeight / 2, + width: max(0, rect.width - Self.titleHorizontalPadding), + height: textHeight + ) + title.draw(in: drawRect) + } + override func drawSortIndicator( withFrame cellFrame: NSRect, in controlView: NSView, diff --git a/TablePro/Views/Results/TableSelection.swift b/TablePro/Views/Results/TableSelection.swift index cca56fdae..0d35e6858 100644 --- a/TablePro/Views/Results/TableSelection.swift +++ b/TablePro/Views/Results/TableSelection.swift @@ -1,15 +1,8 @@ -// -// TableSelection.swift -// TablePro -// - import Foundation struct TableSelection: Equatable { var focusedRow: Int = -1 var focusedColumn: Int = -1 - var anchor: Int = -1 - var pivot: Int = -1 var hasFocus: Bool { focusedRow >= 0 && focusedColumn >= 0 } @@ -25,16 +18,6 @@ struct TableSelection: Equatable { focusedColumn = column } - mutating func resetAnchor(at row: Int) { - anchor = row - pivot = row - } - - mutating func clearAnchor() { - anchor = -1 - pivot = -1 - } - func reloadIndexes(from previous: TableSelection) -> (rows: IndexSet, columns: IndexSet)? { guard previous.focusedRow != focusedRow || previous.focusedColumn != focusedColumn else { return nil diff --git a/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift b/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift index 6230c9e75..d6137b9eb 100644 --- a/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift +++ b/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift @@ -85,7 +85,7 @@ struct DataChangeManagerExtendedTests { rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "A", newValue: "B" ) - _ = manager.undoLastChange() + manager.undoManagerProvider?()?.undo() #expect(manager.canRedo) manager.recordRowInsertion(rowIndex: 5, values: ["a", "b", "c"]) #expect(!manager.canRedo) @@ -335,7 +335,7 @@ struct DataChangeManagerExtendedTests { rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) - _ = manager1.undoLastChange() + manager1.undoManagerProvider?()?.undo() #expect(manager1.canRedo) manager1.discardChanges() #expect(manager1.canRedo) @@ -346,7 +346,7 @@ struct DataChangeManagerExtendedTests { rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) - _ = manager2.undoLastChange() + manager2.undoManagerProvider?()?.undo() #expect(manager2.canRedo) manager2.clearChanges() #expect(!manager2.canUndo) @@ -395,8 +395,8 @@ struct DataChangeManagerExtendedTests { rowIndex: 1, columnIndex: 1, columnName: "name", oldValue: "Charlie", newValue: "Dave" ) - _ = manager.undoLastChange() - _ = manager.undoLastChange() + manager.undoManagerProvider?()?.undo() + manager.undoManagerProvider?()?.undo() #expect(manager.changes.isEmpty) #expect(!manager.hasChanges) } @@ -408,9 +408,9 @@ struct DataChangeManagerExtendedTests { rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "A", newValue: "B" ) - _ = manager.undoLastChange() + manager.undoManagerProvider?()?.undo() #expect(manager.changes.isEmpty) - _ = manager.redoLastChange() + manager.undoManagerProvider?()?.redo() #expect(manager.changes.count == 1) #expect(manager.changes[0].cellChanges[0].newValue == "B") } @@ -419,7 +419,7 @@ struct DataChangeManagerExtendedTests { func undoRowInsertionRemovesFromIndices() { let manager = makeManager() manager.recordRowInsertion(rowIndex: 5, values: ["a", "b", "c"]) - _ = manager.undoLastChange() + manager.undoManagerProvider?()?.undo() #expect(!manager.isRowInserted(5)) } @@ -427,7 +427,7 @@ struct DataChangeManagerExtendedTests { func undoRowDeletionRemovesFromIndices() { let manager = makeManager() manager.recordRowDeletion(rowIndex: 2, originalRow: ["3", "Charlie", "c@test.com"]) - _ = manager.undoLastChange() + manager.undoManagerProvider?()?.undo() #expect(!manager.isRowDeleted(2)) } @@ -435,9 +435,9 @@ struct DataChangeManagerExtendedTests { func undoRowInsertionThenRedoReInserts() { let manager = makeManager() manager.recordRowInsertion(rowIndex: 5, values: ["a", "b", "c"]) - _ = manager.undoLastChange() + manager.undoManagerProvider?()?.undo() #expect(!manager.isRowInserted(5)) - _ = manager.redoLastChange() + manager.undoManagerProvider?()?.redo() #expect(manager.isRowInserted(5)) } @@ -445,9 +445,9 @@ struct DataChangeManagerExtendedTests { func undoRowDeletionThenRedoReDeletes() { let manager = makeManager() manager.recordRowDeletion(rowIndex: 2, originalRow: ["3", "Charlie", "c@test.com"]) - _ = manager.undoLastChange() + manager.undoManagerProvider?()?.undo() #expect(!manager.isRowDeleted(2)) - _ = manager.redoLastChange() + manager.undoManagerProvider?()?.redo() #expect(manager.isRowDeleted(2)) } @@ -464,63 +464,77 @@ struct DataChangeManagerExtendedTests { ) #expect(manager.changes.count == 2) - _ = manager.undoLastChange() + manager.undoManagerProvider?()?.undo() #expect(manager.changes.count == 1) - _ = manager.undoLastChange() + manager.undoManagerProvider?()?.undo() #expect(manager.changes.count == 0) - _ = manager.redoLastChange() + manager.undoManagerProvider?()?.redo() #expect(manager.changes.count == 1) - _ = manager.redoLastChange() + manager.undoManagerProvider?()?.redo() #expect(manager.changes.count == 2) } @Test("Undo returns cell edit action details with correct flags") func undoReturnsCellEditActionDetails() { let manager = makeManager() + var captured: UndoResult? + manager.onUndoApplied = { captured = $0 } manager.recordCellChange( rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) - let result = manager.undoLastChange() - #expect(result != nil) - #expect(result?.needsRowRemoval == false) - #expect(result?.needsRowRestore == false) + manager.undoManagerProvider?()?.undo() + #expect(captured != nil) + #expect(captured?.needsRowRemoval == false) + #expect(captured?.needsRowRestore == false) } @Test("Undo returns row insertion action details with needsRowRemoval") func undoReturnsRowInsertionActionDetails() { let manager = makeManager() + var captured: UndoResult? + manager.onUndoApplied = { captured = $0 } manager.recordRowInsertion(rowIndex: 5, values: ["a", "b", "c"]) - let result = manager.undoLastChange() - #expect(result != nil) - #expect(result?.needsRowRemoval == true) + manager.undoManagerProvider?()?.undo() + #expect(captured != nil) + #expect(captured?.needsRowRemoval == true) } @Test("Undo returns row deletion action details with needsRowRestore and restoreRow") func undoReturnsRowDeletionActionDetails() { let manager = makeManager() + var captured: UndoResult? + manager.onUndoApplied = { captured = $0 } manager.recordRowDeletion(rowIndex: 0, originalRow: ["1", "Alice"]) - let result = manager.undoLastChange() - #expect(result != nil) - #expect(result?.needsRowRestore == true) - #expect(result?.restoreRow == ["1", "Alice"]) + manager.undoManagerProvider?()?.undo() + #expect(captured != nil) + #expect(captured?.needsRowRestore == true) + #expect(captured?.restoreRow == ["1", "Alice"]) } - @Test("Undo returns nil when undo stack is empty") - func undoReturnsNilWhenStackEmpty() { + @Test("Undo does nothing when undo stack is empty") + func undoNoopWhenStackEmpty() { let manager = makeManager() - let result = manager.undoLastChange() - #expect(result == nil) + var captured: UndoResult? + manager.onUndoApplied = { captured = $0 } + let undoManager = manager.undoManagerProvider?() + #expect(undoManager?.canUndo == false) + undoManager?.undo() + #expect(captured == nil) } - @Test("Redo returns nil when redo stack is empty") - func redoReturnsNilWhenStackEmpty() { + @Test("Redo does nothing when redo stack is empty") + func redoNoopWhenStackEmpty() { let manager = makeManager() - let result = manager.redoLastChange() - #expect(result == nil) + var captured: UndoResult? + manager.onUndoApplied = { captured = $0 } + let undoManager = manager.undoManagerProvider?() + #expect(undoManager?.canRedo == false) + undoManager?.redo() + #expect(captured == nil) } // MARK: - Interaction Between Operations @@ -560,7 +574,7 @@ struct DataChangeManagerExtendedTests { rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: nil, newValue: "hello" ) - _ = manager.undoLastChange() + manager.undoManagerProvider?()?.undo() let state = manager.saveState() #expect(state.insertedRowData[0]?[1] == nil) } @@ -633,7 +647,7 @@ struct DataChangeManagerExtendedTests { (rowIndex: 1, originalRow: ["2", "Bob", "b@test.com"]), (rowIndex: 2, originalRow: ["3", "Charlie", "c@test.com"]) ]) - _ = manager.undoLastChange() + manager.undoManagerProvider?()?.undo() #expect(!manager.isRowDeleted(0)) #expect(!manager.isRowDeleted(1)) #expect(!manager.isRowDeleted(2)) @@ -755,7 +769,7 @@ struct DataChangeManagerExtendedTests { rowIndex: 0, columnIndex: 2, columnName: "email", oldValue: "a@test.com", newValue: "b@test.com" ) - _ = manager.undoLastChange() + manager.undoManagerProvider?()?.undo() #expect(!manager.isCellModified(rowIndex: 0, columnIndex: 2)) #expect(manager.isCellModified(rowIndex: 0, columnIndex: 1)) } @@ -767,8 +781,8 @@ struct DataChangeManagerExtendedTests { rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) - _ = manager.undoLastChange() - _ = manager.redoLastChange() + manager.undoManagerProvider?()?.undo() + manager.undoManagerProvider?()?.redo() #expect(manager.isCellModified(rowIndex: 0, columnIndex: 1)) #expect(!manager.changes.isEmpty) } @@ -780,11 +794,11 @@ struct DataChangeManagerExtendedTests { rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob" ) - _ = manager.undoLastChange() - _ = manager.redoLastChange() + manager.undoManagerProvider?()?.undo() + manager.undoManagerProvider?()?.redo() #expect(manager.isCellModified(rowIndex: 0, columnIndex: 1)) - _ = manager.undoLastChange() + manager.undoManagerProvider?()?.undo() #expect(!manager.isCellModified(rowIndex: 0, columnIndex: 1)) #expect(manager.changes.isEmpty) #expect(!manager.hasChanges) diff --git a/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift b/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift index aa70a39f4..b8974d0aa 100644 --- a/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift +++ b/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift @@ -408,7 +408,7 @@ struct DataChangeManagerTests { ) #expect(manager.changes.count == 1) - _ = manager.undoLastChange() + manager.undoManagerProvider?()?.undo() #expect(manager.changes.isEmpty) #expect(!manager.hasChanges) @@ -431,7 +431,7 @@ struct DataChangeManagerTests { newValue: "Bob" ) - _ = manager.undoLastChange() + manager.undoManagerProvider?()?.undo() #expect(manager.canRedo) } @@ -453,7 +453,7 @@ struct DataChangeManagerTests { newValue: "Bob" ) - _ = manager.undoLastChange() + manager.undoManagerProvider?()?.undo() #expect(manager.canRedo) manager.recordCellChange( diff --git a/TableProTests/Views/Results/TableSelectionTests.swift b/TableProTests/Views/Results/TableSelectionTests.swift index e5cc30363..d82aaa493 100644 --- a/TableProTests/Views/Results/TableSelectionTests.swift +++ b/TableProTests/Views/Results/TableSelectionTests.swift @@ -14,8 +14,6 @@ struct TableSelectionTests { let selection = TableSelection() #expect(selection.focusedRow == -1) #expect(selection.focusedColumn == -1) - #expect(selection.anchor == -1) - #expect(selection.pivot == -1) #expect(selection.hasFocus == false) } @@ -30,16 +28,13 @@ struct TableSelectionTests { #expect(selection.hasFocus == false) } - @Test("clearFocus resets focus only") - func clearFocusKeepsAnchor() { + @Test("clearFocus resets focus") + func clearFocus() { var selection = TableSelection() selection.setFocus(row: 5, column: 2) - selection.resetAnchor(at: 5) selection.clearFocus() #expect(selection.focusedRow == -1) #expect(selection.focusedColumn == -1) - #expect(selection.anchor == 5) - #expect(selection.pivot == 5) } @Test("setFocus assigns row and column") @@ -50,28 +45,10 @@ struct TableSelectionTests { #expect(selection.focusedColumn == 3) } - @Test("resetAnchor sets both anchor and pivot") - func resetAnchor() { - var selection = TableSelection() - selection.resetAnchor(at: 4) - #expect(selection.anchor == 4) - #expect(selection.pivot == 4) - } - - @Test("clearAnchor resets anchor and pivot") - func clearAnchor() { - var selection = TableSelection() - selection.resetAnchor(at: 4) - selection.clearAnchor() - #expect(selection.anchor == -1) - #expect(selection.pivot == -1) - } - - @Test("Equatable compares all four fields") + @Test("Equatable compares focus fields") func equatable() { var a = TableSelection() a.setFocus(row: 1, column: 2) - a.resetAnchor(at: 1) var b = a #expect(a == b) b.focusedRow = 2 @@ -89,15 +66,6 @@ struct TableSelectionReloadIndexesTests { #expect(selection.reloadIndexes(from: same) == nil) } - @Test("Anchor change without focus change returns nil") - func anchorOnlyChange() { - var previous = TableSelection() - previous.setFocus(row: 5, column: 2) - var current = previous - current.resetAnchor(at: 8) - #expect(current.reloadIndexes(from: previous) == nil) - } - @Test("Initial focus from empty includes new cell only") func initialFocus() { let previous = TableSelection()