diff --git a/TablePro/Views/Main/Child/DataTabGridDelegate.swift b/TablePro/Views/Main/Child/DataTabGridDelegate.swift index 5777bc4d0..a73cb4db7 100644 --- a/TablePro/Views/Main/Child/DataTabGridDelegate.swift +++ b/TablePro/Views/Main/Child/DataTabGridDelegate.swift @@ -18,6 +18,7 @@ final class DataTabGridDelegate: DataGridViewDelegate { var onCellEdit: ((Int, Int, String?) -> Void)? var onSort: ((Int, Bool, Bool) -> Void)? var onClearSort: (() -> Void)? + var onRemoveSortColumn: ((Int) -> Void)? var onAddRow: (() -> Void)? var onUndoInsert: ((Int) -> Void)? var onFilterColumn: ((String) -> Void)? @@ -37,6 +38,10 @@ final class DataTabGridDelegate: DataGridViewDelegate { onClearSort?() } + func dataGridRemoveSortColumn(_ columnIndex: Int) { + onRemoveSortColumn?(columnIndex) + } + func dataGridAddRow() { onAddRow?() } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index e58dbdebb..13a7d070e 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -31,6 +31,7 @@ struct MainEditorContentView: View { let onCellEdit: (Int, Int, String?) -> Void let onSort: (Int, Bool, Bool) -> Void let onClearSort: () -> Void + let onRemoveSortColumn: (Int) -> Void let onAddRow: () -> Void let onUndoInsert: (Int) -> Void let onSelectionChange: (Set) -> Void @@ -147,6 +148,7 @@ struct MainEditorContentView: View { dataTabDelegate.onCellEdit = onCellEdit dataTabDelegate.onSort = onSort dataTabDelegate.onClearSort = onClearSort + dataTabDelegate.onRemoveSortColumn = onRemoveSortColumn dataTabDelegate.onUndoInsert = onUndoInsert dataTabDelegate.onFilterColumn = onFilterColumn dataTabDelegate.onRefresh = onRefresh diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index b307610c9..cfb7fd11a 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -1422,6 +1422,13 @@ final class MainContentCoordinator { } } + func removeMultiSortColumn(columnIndex: Int) { + guard let tab = tabManager.selectedTab else { return } + guard let existing = tab.sortState.columns.first(where: { $0.columnIndex == columnIndex }) else { return } + let ascending = existing.direction == .ascending + handleSort(columnIndex: columnIndex, ascending: ascending, isMultiSort: true) + } + func clearSort() { guard let (tab, tabIndex) = tabManager.selectedTabAndIndex else { return } guard tab.sortState.isSorting else { return } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 88a89804e..76ce35776 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -413,6 +413,9 @@ struct MainContentView: View { onClearSort: { coordinator.clearSort() }, + onRemoveSortColumn: { columnIndex in + coordinator.removeMultiSortColumn(columnIndex: columnIndex) + }, onAddRow: { coordinator.addNewRow() }, diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 5862bdfee..c2b001e31 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -99,7 +99,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData private(set) var cachedColumnCount: Int = 0 private(set) var enumOrSetColumns: Set = [] private(set) var fkColumns: Set = [] - var isSyncingSortDescriptors: Bool = false var isSyncingSelection = false var isRebuildingColumns: Bool = false var isEscapeCancelling = false diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 31e6c3087..99a13c9dd 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -420,9 +420,6 @@ struct DataGridView: NSViewRepresentable { } private func syncSortDescriptors(tableView: NSTableView, coordinator: TableViewCoordinator, columns: [String]) { - coordinator.isSyncingSortDescriptors = true - defer { coordinator.isSyncingSortDescriptors = false } - coordinator.currentSortState = sortState let primaryIdentifier: NSUserInterfaceItemIdentifier? diff --git a/TablePro/Views/Results/DataGridViewDelegate.swift b/TablePro/Views/Results/DataGridViewDelegate.swift index bea97b67a..79d5ed0f3 100644 --- a/TablePro/Views/Results/DataGridViewDelegate.swift +++ b/TablePro/Views/Results/DataGridViewDelegate.swift @@ -19,6 +19,7 @@ protocol DataGridViewDelegate: AnyObject { func dataGridUndoInsert(at index: Int) func dataGridMoveRow(from source: Int, to destination: Int) func dataGridSort(column: Int, ascending: Bool, isMultiSort: Bool) + func dataGridRemoveSortColumn(_ columnIndex: Int) func dataGridClearSort() func dataGridFilterColumn(_ columnName: String) func dataGridNavigateFK(value: String, fkInfo: ForeignKeyInfo) @@ -47,6 +48,7 @@ extension DataGridViewDelegate { func dataGridUndoInsert(at index: Int) {} func dataGridMoveRow(from source: Int, to destination: Int) {} func dataGridSort(column: Int, ascending: Bool, isMultiSort: Bool) {} + func dataGridRemoveSortColumn(_ columnIndex: Int) {} func dataGridClearSort() {} func dataGridFilterColumn(_ columnName: String) {} func dataGridNavigateFK(value: String, fkInfo: ForeignKeyInfo) {} diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index 7a193c33b..b93e42e82 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -7,29 +7,6 @@ import AppKit import SwiftUI extension TableViewCoordinator { - // MARK: - Native Sorting - - func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) { - guard !isSyncingSortDescriptors else { return } - - guard let newDescriptor = tableView.sortDescriptors.first, - let key = newDescriptor.key, - let columnIndex = dataColumnIndex(from: NSUserInterfaceItemIdentifier(key)), - columnIndex >= 0, columnIndex < tableRowsProvider().columns.count else { - return - } - - if let oldDescriptor = oldDescriptors.first, - oldDescriptor.key == newDescriptor.key, - oldDescriptor.ascending == false, - newDescriptor.ascending == true { - delegate?.dataGridClearSort() - return - } - - delegate?.dataGridSort(column: columnIndex, ascending: newDescriptor.ascending, isMultiSort: false) - } - // MARK: - Double-Click Column Divider Auto-Fit func tableView(_ tableView: NSTableView, sizeToFitWidthOfColumn columnIndex: Int) -> CGFloat { diff --git a/TablePro/Views/Results/SortableHeaderView.swift b/TablePro/Views/Results/SortableHeaderView.swift index 719a67aa8..0cdbf27c3 100644 --- a/TablePro/Views/Results/SortableHeaderView.swift +++ b/TablePro/Views/Results/SortableHeaderView.swift @@ -5,6 +5,82 @@ import AppKit +enum HeaderSortAction: Equatable { + case sort(columnIndex: Int, ascending: Bool, isMultiSort: Bool) + case removeMultiSort(columnIndex: Int) + case clear +} + +struct HeaderSortTransition: Equatable { + let action: HeaderSortAction + let newState: SortState +} + +enum HeaderSortCycle { + static func nextTransition( + state: SortState, + clickedColumn: Int, + isMultiSort: Bool + ) -> HeaderSortTransition { + if isMultiSort { + return multiSortTransition(state: state, clickedColumn: clickedColumn) + } + return singleSortTransition(state: state, clickedColumn: clickedColumn) + } + + private static func multiSortTransition(state: SortState, clickedColumn: Int) -> HeaderSortTransition { + guard let existingIndex = state.columns.firstIndex(where: { $0.columnIndex == clickedColumn }) else { + var newState = state + newState.columns.append(SortColumn(columnIndex: clickedColumn, direction: .ascending)) + return HeaderSortTransition( + action: .sort(columnIndex: clickedColumn, ascending: true, isMultiSort: true), + newState: newState + ) + } + + let existing = state.columns[existingIndex] + switch existing.direction { + case .ascending: + var newState = state + newState.columns[existingIndex].direction = .descending + return HeaderSortTransition( + action: .sort(columnIndex: clickedColumn, ascending: false, isMultiSort: true), + newState: newState + ) + case .descending: + var newState = state + newState.columns.remove(at: existingIndex) + return HeaderSortTransition( + action: .removeMultiSort(columnIndex: clickedColumn), + newState: newState + ) + } + } + + private static func singleSortTransition(state: SortState, clickedColumn: Int) -> HeaderSortTransition { + guard let primary = state.columns.first, primary.columnIndex == clickedColumn else { + var newState = SortState() + newState.columns = [SortColumn(columnIndex: clickedColumn, direction: .ascending)] + return HeaderSortTransition( + action: .sort(columnIndex: clickedColumn, ascending: true, isMultiSort: false), + newState: newState + ) + } + + switch primary.direction { + case .ascending: + var newState = SortState() + newState.columns = [SortColumn(columnIndex: clickedColumn, direction: .descending)] + return HeaderSortTransition( + action: .sort(columnIndex: clickedColumn, ascending: false, isMultiSort: false), + newState: newState + ) + case .descending: + return HeaderSortTransition(action: .clear, newState: SortState()) + } + } +} + @MainActor final class SortableHeaderView: NSTableHeaderView { weak var coordinator: TableViewCoordinator? @@ -78,10 +154,24 @@ final class SortableHeaderView: NSTableHeaderView { return view } + private static let clickDragThreshold: CGFloat = 4 + + private var pendingClickStartLocation: NSPoint? + private var dragOccurredDuringClick = false + + override func mouseDragged(with event: NSEvent) { + if let start = pendingClickStartLocation { + let current = convert(event.locationInWindow, from: nil) + if abs(current.x - start.x) > Self.clickDragThreshold || + abs(current.y - start.y) > Self.clickDragThreshold { + dragOccurredDuringClick = true + } + } + super.mouseDragged(with: event) + } + override func mouseDown(with event: NSEvent) { - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - guard flags.contains(.shift), - let tableView = tableView, + guard let tableView = tableView, let coordinator = coordinator else { super.mouseDown(with: event) return @@ -101,8 +191,58 @@ final class SortableHeaderView: NSTableHeaderView { return } - let existing = coordinator.currentSortState.columns.first(where: { $0.columnIndex == dataIndex }) - let ascending = existing == nil - coordinator.delegate?.dataGridSort(column: dataIndex, ascending: ascending, isMultiSort: true) + let originalColumnOrder = tableView.tableColumns.map { $0.identifier } + let originalColumnWidths = tableView.tableColumns.map { $0.width } + pendingClickStartLocation = pointInHeader + dragOccurredDuringClick = false + defer { + pendingClickStartLocation = nil + dragOccurredDuringClick = false + } + + super.mouseDown(with: event) + + let columnOrderChanged = tableView.tableColumns.map { $0.identifier } != originalColumnOrder + let columnWidthsChanged = tableView.tableColumns.map { $0.width } != originalColumnWidths + if dragOccurredDuringClick || columnOrderChanged || columnWidthsChanged { + return + } + + if let window { + let cursorInWindow = window.convertPoint(fromScreen: NSEvent.mouseLocation) + let cursorInHeader = convert(cursorInWindow, from: nil) + if abs(cursorInHeader.x - pointInHeader.x) > Self.clickDragThreshold || + abs(cursorInHeader.y - pointInHeader.y) > Self.clickDragThreshold { + return + } + } + + let isMultiSort = event.modifierFlags + .intersection(.deviceIndependentFlagsMask) + .contains(.shift) + let transition = HeaderSortCycle.nextTransition( + state: coordinator.currentSortState, + clickedColumn: dataIndex, + isMultiSort: isMultiSort + ) + + coordinator.currentSortState = transition.newState + updateSortIndicators(state: transition.newState, schema: coordinator.identitySchema) + dispatch(transition: transition, on: coordinator) + } + + private func dispatch(transition: HeaderSortTransition, on coordinator: TableViewCoordinator) { + switch transition.action { + case .sort(let columnIndex, let ascending, let isMultiSort): + coordinator.delegate?.dataGridSort( + column: columnIndex, + ascending: ascending, + isMultiSort: isMultiSort + ) + case .removeMultiSort(let columnIndex): + coordinator.delegate?.dataGridRemoveSortColumn(columnIndex) + case .clear: + coordinator.delegate?.dataGridClearSort() + } } } diff --git a/TableProTests/Views/Results/HeaderSortCycleTests.swift b/TableProTests/Views/Results/HeaderSortCycleTests.swift new file mode 100644 index 000000000..9b4653a01 --- /dev/null +++ b/TableProTests/Views/Results/HeaderSortCycleTests.swift @@ -0,0 +1,186 @@ +// +// HeaderSortCycleTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("HeaderSortCycle - single column") +struct HeaderSortCycleSingleColumnTests { + @Test("No active sort starts ascending") + func noActiveSortStartsAscending() { + let transition = HeaderSortCycle.nextTransition( + state: SortState(), + clickedColumn: 2, + isMultiSort: false + ) + #expect(transition.action == .sort(columnIndex: 2, ascending: true, isMultiSort: false)) + #expect(transition.newState.columns == [SortColumn(columnIndex: 2, direction: .ascending)]) + } + + @Test("Ascending on this column advances to descending") + func ascendingAdvancesToDescending() { + var state = SortState() + state.columns = [SortColumn(columnIndex: 2, direction: .ascending)] + let transition = HeaderSortCycle.nextTransition( + state: state, + clickedColumn: 2, + isMultiSort: false + ) + #expect(transition.action == .sort(columnIndex: 2, ascending: false, isMultiSort: false)) + #expect(transition.newState.columns == [SortColumn(columnIndex: 2, direction: .descending)]) + } + + @Test("Descending on this column clears the sort") + func descendingClearsSort() { + var state = SortState() + state.columns = [SortColumn(columnIndex: 2, direction: .descending)] + let transition = HeaderSortCycle.nextTransition( + state: state, + clickedColumn: 2, + isMultiSort: false + ) + #expect(transition.action == .clear) + #expect(transition.newState.columns.isEmpty) + } + + @Test("Different column replaces primary with ascending") + func differentColumnReplacesPrimary() { + var state = SortState() + state.columns = [SortColumn(columnIndex: 1, direction: .descending)] + let transition = HeaderSortCycle.nextTransition( + state: state, + clickedColumn: 4, + isMultiSort: false + ) + #expect(transition.action == .sort(columnIndex: 4, ascending: true, isMultiSort: false)) + #expect(transition.newState.columns == [SortColumn(columnIndex: 4, direction: .ascending)]) + } + + @Test("Multi-column primary cycles independently of secondary") + func multiColumnPrimaryCyclesIndependently() { + var state = SortState() + state.columns = [ + SortColumn(columnIndex: 1, direction: .ascending), + SortColumn(columnIndex: 3, direction: .descending) + ] + let transition = HeaderSortCycle.nextTransition( + state: state, + clickedColumn: 1, + isMultiSort: false + ) + #expect(transition.action == .sort(columnIndex: 1, ascending: false, isMultiSort: false)) + #expect(transition.newState.columns == [SortColumn(columnIndex: 1, direction: .descending)]) + } + + @Test("Click on secondary column without shift replaces primary") + func clickOnSecondaryWithoutShiftReplacesPrimary() { + var state = SortState() + state.columns = [ + SortColumn(columnIndex: 1, direction: .ascending), + SortColumn(columnIndex: 3, direction: .descending) + ] + let transition = HeaderSortCycle.nextTransition( + state: state, + clickedColumn: 3, + isMultiSort: false + ) + #expect(transition.action == .sort(columnIndex: 3, ascending: true, isMultiSort: false)) + #expect(transition.newState.columns == [SortColumn(columnIndex: 3, direction: .ascending)]) + } +} + +@Suite("HeaderSortCycle - multi-column shift-click") +struct HeaderSortCycleMultiColumnTests { + @Test("Shift-click on unsorted column adds it ascending") + func shiftClickUnsortedAddsAscending() { + var state = SortState() + state.columns = [SortColumn(columnIndex: 1, direction: .ascending)] + let transition = HeaderSortCycle.nextTransition( + state: state, + clickedColumn: 3, + isMultiSort: true + ) + #expect(transition.action == .sort(columnIndex: 3, ascending: true, isMultiSort: true)) + #expect(transition.newState.columns == [ + SortColumn(columnIndex: 1, direction: .ascending), + SortColumn(columnIndex: 3, direction: .ascending) + ]) + } + + @Test("Shift-click on existing ascending column toggles to descending") + func shiftClickAscendingTogglesToDescending() { + var state = SortState() + state.columns = [ + SortColumn(columnIndex: 1, direction: .ascending), + SortColumn(columnIndex: 3, direction: .ascending) + ] + let transition = HeaderSortCycle.nextTransition( + state: state, + clickedColumn: 3, + isMultiSort: true + ) + #expect(transition.action == .sort(columnIndex: 3, ascending: false, isMultiSort: true)) + #expect(transition.newState.columns == [ + SortColumn(columnIndex: 1, direction: .ascending), + SortColumn(columnIndex: 3, direction: .descending) + ]) + } + + @Test("Shift-click on existing descending column removes it from sort") + func shiftClickDescendingRemovesColumn() { + var state = SortState() + state.columns = [ + SortColumn(columnIndex: 1, direction: .ascending), + SortColumn(columnIndex: 3, direction: .descending) + ] + let transition = HeaderSortCycle.nextTransition( + state: state, + clickedColumn: 3, + isMultiSort: true + ) + #expect(transition.action == .removeMultiSort(columnIndex: 3)) + #expect(transition.newState.columns == [SortColumn(columnIndex: 1, direction: .ascending)]) + } + + @Test("Shift-click on empty state adds ascending") + func shiftClickEmptyAddsAscending() { + let transition = HeaderSortCycle.nextTransition( + state: SortState(), + clickedColumn: 0, + isMultiSort: true + ) + #expect(transition.action == .sort(columnIndex: 0, ascending: true, isMultiSort: true)) + #expect(transition.newState.columns == [SortColumn(columnIndex: 0, direction: .ascending)]) + } + + @Test("Shift-click cycle: add then toggle then remove preserves siblings") + func shiftClickFullCyclePreservesSiblings() { + var state = SortState() + state.columns = [SortColumn(columnIndex: 1, direction: .ascending)] + + let added = HeaderSortCycle.nextTransition(state: state, clickedColumn: 5, isMultiSort: true) + #expect(added.action == .sort(columnIndex: 5, ascending: true, isMultiSort: true)) + #expect(added.newState.columns == [ + SortColumn(columnIndex: 1, direction: .ascending), + SortColumn(columnIndex: 5, direction: .ascending) + ]) + + let toggled = HeaderSortCycle.nextTransition( + state: added.newState, clickedColumn: 5, isMultiSort: true + ) + #expect(toggled.action == .sort(columnIndex: 5, ascending: false, isMultiSort: true)) + #expect(toggled.newState.columns == [ + SortColumn(columnIndex: 1, direction: .ascending), + SortColumn(columnIndex: 5, direction: .descending) + ]) + + let removed = HeaderSortCycle.nextTransition( + state: toggled.newState, clickedColumn: 5, isMultiSort: true + ) + #expect(removed.action == .removeMultiSort(columnIndex: 5)) + #expect(removed.newState.columns == [SortColumn(columnIndex: 1, direction: .ascending)]) + } +}