diff --git a/CHANGELOG.md b/CHANGELOG.md index 8889b7ea..524f81db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - PostgreSQL: Schema name lost after app restart, causing "relation does not exist" errors for non-public schemas - Error dialog OK button not dismissing when a SwiftUI sheet is active, making the app unusable +- Shift+Click on column header not triggering multi-column sort (NSTableView swallows Shift+Click on headers) - SQL Server: Unicode characters (Thai, CJK, etc.) in nvarchar/nchar/ntext columns displaying as question marks - Globe+F (fn+F) fullscreen shortcut not working in SwiftUI lifecycle app diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 920610f7..d17d4f65 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -32,6 +32,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var onHideColumn: ((String) -> Void)? var onMoveRow: ((Int, Int) -> Void)? var onNavigateFK: ((String, ForeignKeyInfo) -> Void)? + var sortState = SortState() var getVisualState: ((Int) -> RowVisualState)? var dropdownColumns: Set? var typePickerColumns: Set? diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 0888fbad..c78e8e79 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -83,6 +83,9 @@ struct DataGridView: NSViewRepresentable { let tableView = KeyHandlingTableView() tableView.coordinator = context.coordinator + let sortableHeader = SortableTableHeaderView() + sortableHeader.coordinator = context.coordinator + tableView.headerView = sortableHeader tableView.style = .plain tableView.setAccessibilityLabel(String(localized: "Data grid")) tableView.setAccessibilityRole(.table) @@ -176,6 +179,7 @@ struct DataGridView: NSViewRepresentable { scrollView.documentView = tableView context.coordinator.tableView = tableView + context.coordinator.sortState = sortState context.coordinator.onMoveRow = onMoveRow context.coordinator.rebuildColumnMetadataCache() if let connectionId { @@ -229,6 +233,7 @@ struct DataGridView: NSViewRepresentable { ) if currentIdentity == coordinator.lastIdentity { // Only refresh closure callbacks — they capture new state on each body eval + coordinator.sortState = sortState coordinator.onCellEdit = onCellEdit coordinator.onSort = onSort coordinator.onAddRow = onAddRow @@ -299,6 +304,7 @@ struct DataGridView: NSViewRepresentable { coordinator.onRefresh = onRefresh coordinator.onCellEdit = onCellEdit coordinator.onDeleteRows = onDeleteRows + coordinator.sortState = sortState coordinator.onSort = onSort coordinator.onAddRow = onAddRow coordinator.onUndoInsert = onUndoInsert @@ -504,7 +510,6 @@ struct DataGridView: NSViewRepresentable { } } - /// Synchronize sort descriptors and indicators with the table view private func syncSortDescriptors(tableView: NSTableView, coordinator: TableViewCoordinator) { coordinator.isSyncingSortDescriptors = true defer { coordinator.isSyncingSortDescriptors = false } @@ -513,18 +518,16 @@ struct DataGridView: NSViewRepresentable { if !tableView.sortDescriptors.isEmpty { tableView.sortDescriptors = [] } - } 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 ascending = firstSort.direction == .ascending - let currentDescriptor = tableView.sortDescriptors.first - if currentDescriptor?.key != key || currentDescriptor?.ascending != ascending { - tableView.sortDescriptors = [NSSortDescriptor(key: key, ascending: ascending)] + } else { + let descriptors = sortState.columns.compactMap { sortCol -> NSSortDescriptor? in + guard sortCol.columnIndex >= 0 && sortCol.columnIndex < rowProvider.columns.count else { return nil } + return NSSortDescriptor(key: "col_\(sortCol.columnIndex)", ascending: sortCol.direction == .ascending) + } + if tableView.sortDescriptors != descriptors { + tableView.sortDescriptors = descriptors } } - // Update column header titles for multi-sort indicators Self.updateSortIndicators(tableView: tableView, sortState: sortState, columns: rowProvider.columns) } @@ -662,19 +665,17 @@ struct DataGridView: NSViewRepresentable { guard let colIndex = Int(idString.dropFirst(4)), colIndex < columns.count else { continue } - let baseName = columns[colIndex] - - if let sortIndex = sortState.columns.firstIndex(where: { $0.columnIndex == colIndex }) { - let sortCol = sortState.columns[sortIndex] - if sortState.columns.count > 1 { - let indicator = " \(sortIndex + 1)\(sortCol.direction.indicator)" - column.title = "\(baseName)\(indicator)" - } else { - // Single sort: NSTableView shows its own indicator, keep base name - column.title = baseName - } + if let sortCol = sortState.columns.first(where: { $0.columnIndex == colIndex }) { + let imageName = sortCol.direction == .ascending + ? "NSAscendingSortIndicator" + : "NSDescendingSortIndicator" + tableView.setIndicatorImage(NSImage(named: imageName), in: column) } else { - // Not sorted: restore base name + tableView.setIndicatorImage(nil, in: column) + } + + let baseName = columns[colIndex] + if column.title != baseName { column.title = baseName } } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index ba9b2848..35c7152c 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -24,6 +24,14 @@ extension TableViewCoordinator { onSort?(columnIndex, sortDescriptor.ascending, isMultiSort) } + // NSTableView swallows Shift+Click on headers, so SortableTableHeaderView calls this directly. + // Cycles: not sorted -> ascending -> descending -> remove + func handleShiftClickSort(columnIndex: Int) { + guard columnIndex >= 0 && columnIndex < rowProvider.columns.count else { return } + let ascending = !sortState.columns.contains(where: { $0.columnIndex == columnIndex }) + onSort?(columnIndex, ascending, true) + } + // MARK: - Double-Click Column Divider Auto-Fit func tableView(_ tableView: NSTableView, sizeToFitWidthOfColumn columnIndex: Int) -> CGFloat { diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index efe21e37..1156b16d 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -422,3 +422,37 @@ final class KeyHandlingTableView: NSTableView { return super.menu(for: event) } } + +// MARK: - Sortable Table Header View + +/// Custom NSTableHeaderView that intercepts Shift+Click for multi-column sort. +/// NSTableView swallows Shift+Click on column headers (sortDescriptorsDidChange never fires), +/// so we detect the modifier here and route to the coordinator's multi-sort handler. +final class SortableTableHeaderView: NSTableHeaderView { + weak var coordinator: TableViewCoordinator? + + override func mouseDown(with event: NSEvent) { + guard event.modifierFlags.contains(.shift) else { + super.mouseDown(with: event) + return + } + + // Shift is held — handle multi-sort manually + let pointInHeader = convert(event.locationInWindow, from: nil) + let clickedColumn = column(at: pointInHeader) + guard clickedColumn >= 0, + let tableView, + clickedColumn < tableView.tableColumns.count else { + super.mouseDown(with: event) + return + } + + let column = tableView.tableColumns[clickedColumn] + guard let dataColumnIndex = DataGridView.columnIndex(from: column.identifier) else { + super.mouseDown(with: event) + return + } + + coordinator?.handleShiftClickSort(columnIndex: dataColumnIndex) + } +}