From 3c5e27816b9991f274671c8bfa9ecf33c1df5fb5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 31 Mar 2026 22:16:45 +0700 Subject: [PATCH 1/3] fix: Shift+Click on column header now triggers multi-column sort (#541) --- CHANGELOG.md | 1 + TablePro/Views/Results/DataGridView.swift | 3 ++ .../Extensions/DataGridView+Sort.swift | 8 +++++ .../Views/Results/KeyHandlingTableView.swift | 34 +++++++++++++++++++ 4 files changed, 46 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76173022..6f134bec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,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/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 0888fbad..3e30d1cc 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) diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index ba9b2848..f5fee8b1 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) } + /// Handle Shift+Click on column header for multi-column sort. + /// NSTableView swallows Shift+Click on headers (sortDescriptorsDidChange never fires), + /// so the custom SortableTableHeaderView calls this directly. + func handleShiftClickSort(columnIndex: Int) { + guard columnIndex >= 0 && columnIndex < rowProvider.columns.count else { return } + onSort?(columnIndex, true, 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) + } +} From ef5890227b88050abfa77c911a9fdef4ece87f6c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 1 Apr 2026 18:24:44 +0700 Subject: [PATCH 2/3] fix: correct Shift+Click multi-column sort direction cycling --- TablePro/Views/Results/DataGridCoordinator.swift | 1 + TablePro/Views/Results/DataGridView.swift | 3 +++ TablePro/Views/Results/Extensions/DataGridView+Sort.swift | 8 ++++---- 3 files changed, 8 insertions(+), 4 deletions(-) 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 3e30d1cc..47585640 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -179,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 { @@ -232,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 @@ -302,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 diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index f5fee8b1..35c7152c 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -24,12 +24,12 @@ extension TableViewCoordinator { onSort?(columnIndex, sortDescriptor.ascending, isMultiSort) } - /// Handle Shift+Click on column header for multi-column sort. - /// NSTableView swallows Shift+Click on headers (sortDescriptorsDidChange never fires), - /// so the custom SortableTableHeaderView calls this directly. + // 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 } - onSort?(columnIndex, true, true) + let ascending = !sortState.columns.contains(where: { $0.columnIndex == columnIndex }) + onSort?(columnIndex, ascending, true) } // MARK: - Double-Click Column Divider Auto-Fit From 9e77b01a5999d93085d4ccc0a4493bebe7106425 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 1 Apr 2026 18:39:48 +0700 Subject: [PATCH 3/3] refactor: use native NSTableView sort indicators instead of custom title modification --- TablePro/Views/Results/DataGridView.swift | 39 ++++++++++------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 47585640..c78e8e79 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -510,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 } @@ -519,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) } @@ -668,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 } }