Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
8c272c6
refactor(datagrid): native sort indicators, FK button reuse fix, drop…
datlechin Apr 29, 2026
3922ad5
refactor(datagrid): name-based column identifiers, protocol-based lay…
datlechin Apr 29, 2026
e7daf8d
refactor(datagrid): address review — split persister protocol, drop r…
datlechin Apr 29, 2026
f8438f0
fix(datagrid): compare column sets not arrays so user reorder does no…
datlechin Apr 29, 2026
0f97a36
diag(datagrid): trace sort dispatch + SF Symbol fallback for sort ind…
datlechin Apr 29, 2026
e266472
fix(datagrid): read live shift modifier from NSEvent for multi-sort d…
datlechin Apr 29, 2026
f573150
diag(datagrid): expand sort logging — modifier flags raw, indicator p…
datlechin Apr 29, 2026
c693a79
fix(datagrid): intercept shift+click in custom NSTableHeaderView for …
datlechin Apr 29, 2026
4c25c34
fix(datagrid): set all sort descriptors so multi-sort indicators rend…
datlechin Apr 29, 2026
ecf0017
fix(datagrid): custom NSTableHeaderCell renders indicators on every m…
datlechin Apr 29, 2026
99308fc
fix(datagrid): use native NSAscendingSortIndicator at natural size, d…
datlechin Apr 29, 2026
d91541a
fix(datagrid): clear AppKit's auto sort indicator so custom cell does…
datlechin Apr 29, 2026
90a1722
fix(datagrid): override drawSortIndicator to no-op so AppKit stops dr…
datlechin Apr 29, 2026
a955be9
fix(datagrid): tint native sort indicator template image with seconda…
datlechin Apr 29, 2026
b295cfe
fix(datagrid): tint sort indicator template image in offscreen contex…
datlechin Apr 29, 2026
02f81f5
fix(datagrid): tint sort indicator with tertiaryLabelColor for native…
datlechin Apr 29, 2026
bab57ac
fix(datagrid): use SF Symbol chevron with light weight for sort indic…
datlechin Apr 29, 2026
6223708
fix(datagrid): tweak sort indicator alpha to 0.4 between secondary an…
datlechin Apr 29, 2026
c265258
refactor(datagrid): use AppKit's native NSTableHeaderCell.drawSortInd…
datlechin Apr 29, 2026
c202227
fix(datagrid): SF Symbol chevron with paletteColors config for native…
datlechin Apr 29, 2026
9c57e2b
fix(datagrid): reduce sort indicator pointSize to 9 for smaller chevron
datlechin Apr 29, 2026
f4d9c9e
feat(datagrid): 3-state sort cycle + 'Don't Sort' context menu — nati…
datlechin Apr 29, 2026
fd88b23
refactor(datagrid): split sort handling — AppKit drives single-click …
datlechin Apr 29, 2026
09f5222
i18n: add 'Don't Sort' string to Localizable catalog
datlechin Apr 29, 2026
cce7b95
fix(datagrid): override init(coder:) + copy(with:) on MultiSortHeader…
datlechin Apr 29, 2026
dabb7cc
fix(datagrid): drop SF Symbol paletteColors config, tint at draw-time…
datlechin Apr 29, 2026
cbc5611
fix(datagrid): rip out MultiSortHeaderCell, use pure native NSTableVi…
datlechin Apr 29, 2026
b20e8a8
refactor(datagrid): NSImageView overlays on header for multi-sort ind…
datlechin Apr 29, 2026
6964084
fix(datagrid): clear highlightedTableColumn so AppKit does not auto-d…
datlechin Apr 29, 2026
e0918b3
fix(datagrid): explicit setIndicatorImage nil to clear AppKit auto-in…
datlechin Apr 29, 2026
cd222f2
fix(datagrid): stateless NSTableHeaderCell subclass overrides drawSor…
datlechin Apr 29, 2026
5c01de2
Merge branch 'main' into refactor/datagrid-column-architecture
datlechin Apr 29, 2026
ffe9fe5
refactor(datagrid): inject ColumnLayoutPersisting per view, drop shar…
datlechin Apr 29, 2026
7ea29f5
fix(datagrid): preserve native sort cycle on column header clicks
datlechin Apr 29, 2026
a337b90
fix(a11y): announce sort direction on data grid column headers
datlechin Apr 29, 2026
a67bcd4
refactor(datagrid): drop redundant doc comment on visibility helper
datlechin Apr 29, 2026
ace2733
test(datagrid): cover sort transitions, persister corruption, and lit…
datlechin Apr 29, 2026
6acf667
docs: correct sort indicator implementation note in CHANGELOG
datlechin Apr 29, 2026
d6b212a
fix(datagrid): restore three-state sort cycle (asc, desc, none)
datlechin Apr 29, 2026
232a476
refactor(datagrid): consolidate sort click cycle into header view
datlechin Apr 29, 2026
36399c4
Merge remote-tracking branch 'origin/main' into refactor/datagrid-sor…
datlechin Apr 29, 2026
faec0ba
fix(datagrid): cycle multi-sort columns through ascending, descending…
datlechin Apr 29, 2026
550c93e
fix(datagrid): apply optimistic sort state update on click to avoid s…
datlechin Apr 29, 2026
d934c19
fix(datagrid): preserve column reorder drag by deferring sort dispatc…
datlechin Apr 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions TablePro/Views/Main/Child/DataTabGridDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)?
Expand All @@ -37,6 +38,10 @@ final class DataTabGridDelegate: DataGridViewDelegate {
onClearSort?()
}

func dataGridRemoveSortColumn(_ columnIndex: Int) {
onRemoveSortColumn?(columnIndex)
}

func dataGridAddRow() {
onAddRow?()
}
Expand Down
2 changes: 2 additions & 0 deletions TablePro/Views/Main/Child/MainEditorContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int>) -> Void
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Views/Main/MainContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,9 @@ struct MainContentView: View {
onClearSort: {
coordinator.clearSort()
},
onRemoveSortColumn: { columnIndex in
coordinator.removeMultiSortColumn(columnIndex: columnIndex)
},
onAddRow: {
coordinator.addNewRow()
},
Expand Down
1 change: 0 additions & 1 deletion TablePro/Views/Results/DataGridCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
private(set) var cachedColumnCount: Int = 0
private(set) var enumOrSetColumns: Set<Int> = []
private(set) var fkColumns: Set<Int> = []
var isSyncingSortDescriptors: Bool = false
var isSyncingSelection = false
var isRebuildingColumns: Bool = false
var isEscapeCancelling = false
Expand Down
3 changes: 0 additions & 3 deletions TablePro/Views/Results/DataGridView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
2 changes: 2 additions & 0 deletions TablePro/Views/Results/DataGridViewDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {}
Expand Down
23 changes: 0 additions & 23 deletions TablePro/Views/Results/Extensions/DataGridView+Sort.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
152 changes: 146 additions & 6 deletions TablePro/Views/Results/SortableHeaderView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand All @@ -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()
}
}
}
Loading
Loading