Skip to content

Commit 3437002

Browse files
committed
refactor(datagrid): cell-based sort indicators with native divider behavior
1 parent a46dfe6 commit 3437002

6 files changed

Lines changed: 282 additions & 87 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2626
- Data grid column identifiers are now the column name (with positional fallback for duplicate names), so saved widths follow the column across schema changes that shift its position. Identifier resolution moved from static `DataGridView` helpers to a `ColumnIdentitySchema` value type owned by the coordinator.
2727
- `ColumnLayoutStorage` singleton replaced by a `ColumnLayoutPersisting` protocol with an injectable `FileColumnLayoutPersister` default. The coordinator depends on the protocol, not the concrete class, so tests can substitute a fake.
2828
- Column layout save/restore on table-switch (`saveColumnLayoutForTable` / `restoreColumnLayoutForTable`) folded into the data grid coordinator's lifecycle (load on column build, persist on resize/move/dismantle). The standalone `MainContentCoordinator+ColumnLayout` extension is gone; only the visibility orchestration remains. Removes the redundant `hasUserResizedColumns` flag and the external save trigger from the binding setter.
29-
- Data grid header sort indicators are `NSImageView` overlays drawn on a custom `NSTableHeaderView`, replacing Unicode arrows that were embedded in the column title string. The primary sorted column also gets the system header tint via `highlightedTableColumn`. VoiceOver announces the column name and sort direction separately.
29+
- Data grid header sort indicators are drawn inside a custom `NSTableHeaderCell` instead of overlay `NSImageView` subviews, replacing Unicode arrows that were embedded in the column title string. Removing the overlay subviews lets `NSTableHeaderView`'s native cursor management run unimpeded, so the column resize cursor on hover works without any custom cursor handling. The primary sorted column gets the system header tint via `highlightedTableColumn`, and secondary sort columns show a small priority number to the left of the arrow.
30+
- Data grid header divider taps trigger a column resize instead of sorting the adjacent column. `SortableHeaderView` checks if the click landed within 4 pt of a column edge and forwards the event to `NSTableHeaderView`'s native resize handling.
3031
- Data grid column layout persistence routes through a coordinator callback fired from outside SwiftUI's update cycle, removing the `Task`-based `@Binding` mutation inside `updateNSView` and the `isWritingColumnLayout` re-entry guard.
3132
- Data grid cell reuse resets foreign-key arrow and dropdown chevron button context (target, action, row, column) when the button hides, preventing a stale handler from firing the wrong row if the column toggles between FK-eligible and not.
3233
- `applyColumnOrder` scans only the unsettled tail of the column array per move, halving the constant cost on reorders with many columns.

TablePro/Views/Results/DataGridView.swift

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,10 @@ struct DataGridView: NSViewRepresentable {
8686
for (index, columnName) in initialRows.columns.enumerated() {
8787
guard let identifier = identitySchema.identifier(for: index) else { continue }
8888
let column = NSTableColumn(identifier: identifier)
89-
let suppressedCell = SuppressedSortIndicatorCell(textCell: columnName)
90-
suppressedCell.font = column.headerCell.font
91-
suppressedCell.alignment = column.headerCell.alignment
92-
column.headerCell = suppressedCell
89+
let sortableCell = SortableHeaderCell(textCell: columnName)
90+
sortableCell.font = column.headerCell.font
91+
sortableCell.alignment = column.headerCell.alignment
92+
column.headerCell = sortableCell
9393
if index < initialRows.columnTypes.count {
9494
let typeName = initialRows.columnTypes[index].rawType ?? initialRows.columnTypes[index].displayName
9595
column.headerToolTip = "\(columnName) (\(typeName))"
@@ -332,10 +332,10 @@ struct DataGridView: NSViewRepresentable {
332332
for (index, columnName) in tableRows.columns.enumerated() {
333333
guard let identifier = schema.identifier(for: index) else { continue }
334334
let column = NSTableColumn(identifier: identifier)
335-
let suppressedCell = SuppressedSortIndicatorCell(textCell: columnName)
336-
suppressedCell.font = column.headerCell.font
337-
suppressedCell.alignment = column.headerCell.alignment
338-
column.headerCell = suppressedCell
335+
let sortableCell = SortableHeaderCell(textCell: columnName)
336+
sortableCell.font = column.headerCell.font
337+
sortableCell.alignment = column.headerCell.alignment
338+
column.headerCell = sortableCell
339339
if index < tableRows.columnTypes.count {
340340
let typeName = tableRows.columnTypes[index].rawType
341341
?? tableRows.columnTypes[index].displayName
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
//
2+
// SortableHeaderCell.swift
3+
// TablePro
4+
//
5+
6+
import AppKit
7+
8+
@MainActor
9+
final class SortableHeaderCell: NSTableHeaderCell {
10+
var sortDirection: SortDirection?
11+
var sortPriority: Int?
12+
13+
private static let indicatorPadding: CGFloat = 4
14+
private static let indicatorSpacing: CGFloat = 2
15+
private static let priorityFontSize: CGFloat = 9
16+
17+
override init(textCell string: String) {
18+
super.init(textCell: string)
19+
}
20+
21+
required init(coder: NSCoder) {
22+
super.init(coder: coder)
23+
}
24+
25+
override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) {
26+
guard let direction = sortDirection else {
27+
super.drawInterior(withFrame: cellFrame, in: controlView)
28+
return
29+
}
30+
31+
let indicatorImage = Self.indicatorImage(for: direction)
32+
let indicatorSize = indicatorImage?.size ?? NSSize(width: 9, height: 6)
33+
let priorityText = priorityNumberString()
34+
let priorityWidth = priorityText.map { Self.measureWidth(of: $0) } ?? 0
35+
let reservedWidth = indicatorSize.width
36+
+ Self.indicatorPadding * 2
37+
+ (priorityText == nil ? 0 : priorityWidth + Self.indicatorSpacing)
38+
39+
let titleFrame = NSRect(
40+
x: cellFrame.minX,
41+
y: cellFrame.minY,
42+
width: max(0, cellFrame.width - reservedWidth),
43+
height: cellFrame.height
44+
)
45+
super.drawInterior(withFrame: titleFrame, in: controlView)
46+
47+
let indicatorOriginX = cellFrame.maxX - Self.indicatorPadding - indicatorSize.width
48+
let indicatorOriginY = cellFrame.midY - indicatorSize.height / 2
49+
let indicatorRect = NSRect(
50+
x: indicatorOriginX,
51+
y: indicatorOriginY,
52+
width: indicatorSize.width,
53+
height: indicatorSize.height
54+
)
55+
Self.drawTintedIndicator(image: indicatorImage, in: indicatorRect)
56+
57+
if let priorityText {
58+
let textOriginX = indicatorOriginX - Self.indicatorSpacing - priorityWidth
59+
let textRect = NSRect(
60+
x: textOriginX,
61+
y: cellFrame.minY,
62+
width: priorityWidth,
63+
height: cellFrame.height
64+
)
65+
Self.drawPriorityText(priorityText, in: textRect)
66+
}
67+
}
68+
69+
override func drawSortIndicator(
70+
withFrame cellFrame: NSRect,
71+
in controlView: NSView,
72+
ascending: Bool,
73+
priority: Int
74+
) {}
75+
76+
private func priorityNumberString() -> String? {
77+
guard let sortPriority, sortPriority >= 2 else { return nil }
78+
return String(sortPriority)
79+
}
80+
81+
private static func indicatorImage(for direction: SortDirection) -> NSImage? {
82+
switch direction {
83+
case .ascending:
84+
return NSImage(named: NSImage.Name("NSAscendingSortIndicator"))
85+
case .descending:
86+
return NSImage(named: NSImage.Name("NSDescendingSortIndicator"))
87+
}
88+
}
89+
90+
private static func drawTintedIndicator(image: NSImage?, in rect: NSRect) {
91+
guard let image else { return }
92+
image.draw(
93+
in: rect,
94+
from: .zero,
95+
operation: .sourceOver,
96+
fraction: 1.0,
97+
respectFlipped: true,
98+
hints: nil
99+
)
100+
}
101+
102+
private static func drawPriorityText(_ text: String, in rect: NSRect) {
103+
let attributes = priorityAttributes()
104+
let textSize = (text as NSString).size(withAttributes: attributes)
105+
let drawRect = NSRect(
106+
x: rect.minX,
107+
y: rect.midY - textSize.height / 2,
108+
width: rect.width,
109+
height: textSize.height
110+
)
111+
(text as NSString).draw(in: drawRect, withAttributes: attributes)
112+
}
113+
114+
private static func measureWidth(of text: String) -> CGFloat {
115+
(text as NSString).size(withAttributes: priorityAttributes()).width
116+
}
117+
118+
private static func priorityAttributes() -> [NSAttributedString.Key: Any] {
119+
[
120+
.font: NSFont.systemFont(ofSize: priorityFontSize, weight: .medium),
121+
.foregroundColor: NSColor.secondaryLabelColor
122+
]
123+
}
124+
}

TablePro/Views/Results/SortableHeaderView.swift

Lines changed: 94 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -85,79 +85,114 @@ enum HeaderSortCycle {
8585
final class SortableHeaderView: NSTableHeaderView {
8686
weak var coordinator: TableViewCoordinator?
8787

88-
private var indicatorViews: [String: NSImageView] = [:]
89-
private static let ascendingImage = NSImage(named: NSImage.Name("NSAscendingSortIndicator"))
90-
private static let descendingImage = NSImage(named: NSImage.Name("NSDescendingSortIndicator"))
88+
private static let clickDragThreshold: CGFloat = 4
89+
private static let resizeZoneWidth: CGFloat = 4
9190

92-
func updateSortIndicators(state: SortState, schema: ColumnIdentitySchema) {
93-
let activeKeys: Set<String> = Set(state.columns.compactMap {
94-
schema.identifier(for: $0.columnIndex)?.rawValue
95-
})
91+
private var pendingClickStartLocation: NSPoint?
92+
private var dragOccurredDuringClick = false
93+
private var mouseMovedTrackingArea: NSTrackingArea?
9694

97-
for (key, view) in indicatorViews where !activeKeys.contains(key) {
98-
view.removeFromSuperview()
99-
indicatorViews.removeValue(forKey: key)
100-
}
95+
override init(frame frameRect: NSRect) {
96+
super.init(frame: frameRect)
97+
}
10198

102-
for sortCol in state.columns {
103-
guard let identifier = schema.identifier(for: sortCol.columnIndex) else { continue }
104-
let view = indicatorViews[identifier.rawValue] ?? makeIndicatorView()
105-
view.image = sortCol.direction == .ascending ? Self.ascendingImage : Self.descendingImage
106-
view.setAccessibilityLabel(
107-
sortCol.direction == .ascending
108-
? String(localized: "Sort ascending")
109-
: String(localized: "Sort descending")
99+
required init?(coder: NSCoder) {
100+
super.init(coder: coder)
101+
}
102+
103+
override func resetCursorRects() {
104+
super.resetCursorRects()
105+
guard let tableView = tableView else { return }
106+
let zoneWidth = Self.resizeZoneWidth
107+
for (index, column) in tableView.tableColumns.enumerated() {
108+
guard column.resizingMask.contains(.userResizingMask) else { continue }
109+
let columnRect = headerRect(ofColumn: index)
110+
let cursorRect = NSRect(
111+
x: columnRect.maxX - zoneWidth,
112+
y: columnRect.minY,
113+
width: zoneWidth * 2,
114+
height: columnRect.height
110115
)
111-
if view.superview == nil {
112-
addSubview(view)
113-
}
114-
indicatorViews[identifier.rawValue] = view
116+
addCursorRect(cursorRect, cursor: .resizeLeftRight)
115117
}
118+
}
116119

117-
repositionIndicators()
120+
override func viewDidMoveToWindow() {
121+
super.viewDidMoveToWindow()
122+
window?.acceptsMouseMovedEvents = true
123+
window?.invalidateCursorRects(for: self)
118124
}
119125

120126
override func layout() {
121127
super.layout()
122-
repositionIndicators()
128+
window?.invalidateCursorRects(for: self)
123129
}
124130

125-
private func repositionIndicators() {
131+
override func updateTrackingAreas() {
132+
super.updateTrackingAreas()
133+
if let existing = mouseMovedTrackingArea {
134+
removeTrackingArea(existing)
135+
}
136+
let area = NSTrackingArea(
137+
rect: bounds,
138+
options: [.activeInKeyWindow, .mouseMoved, .inVisibleRect],
139+
owner: self,
140+
userInfo: nil
141+
)
142+
addTrackingArea(area)
143+
mouseMovedTrackingArea = area
144+
}
145+
146+
override func mouseMoved(with event: NSEvent) {
147+
guard let tableView = tableView else {
148+
super.mouseMoved(with: event)
149+
return
150+
}
151+
let point = convert(event.locationInWindow, from: nil)
152+
if isInResizeZone(point, in: tableView) {
153+
NSCursor.resizeLeftRight.set()
154+
} else {
155+
NSCursor.arrow.set()
156+
}
157+
}
158+
159+
func updateSortIndicators(state: SortState, schema: ColumnIdentitySchema) {
126160
guard let tableView = tableView else { return }
127-
let padding: CGFloat = 4
128-
129-
for (key, view) in indicatorViews {
130-
let identifier = NSUserInterfaceItemIdentifier(key)
131-
let columnIndex = tableView.column(withIdentifier: identifier)
132-
guard columnIndex >= 0 else {
133-
view.isHidden = true
134-
continue
161+
162+
var priorityByIdentifier: [NSUserInterfaceItemIdentifier: (direction: SortDirection, priority: Int)] = [:]
163+
for (index, sortCol) in state.columns.enumerated() {
164+
guard let identifier = schema.identifier(for: sortCol.columnIndex) else { continue }
165+
priorityByIdentifier[identifier] = (sortCol.direction, index + 1)
166+
}
167+
168+
for (columnIndex, column) in tableView.tableColumns.enumerated() {
169+
guard let cell = column.headerCell as? SortableHeaderCell else { continue }
170+
let entry = priorityByIdentifier[column.identifier]
171+
let newDirection = entry?.direction
172+
let newPriority = entry?.priority
173+
if cell.sortDirection != newDirection || cell.sortPriority != newPriority {
174+
cell.sortDirection = newDirection
175+
cell.sortPriority = newPriority
176+
setNeedsDisplay(headerRect(ofColumn: columnIndex))
135177
}
136-
view.isHidden = false
137-
let columnRect = headerRect(ofColumn: columnIndex)
138-
let imageSize = view.image?.size ?? NSSize(width: 9, height: 6)
139-
view.frame = NSRect(
140-
x: columnRect.maxX - imageSize.width - padding,
141-
y: columnRect.midY - imageSize.height / 2,
142-
width: imageSize.width,
143-
height: imageSize.height
144-
)
145178
}
146179
}
147180

148-
private func makeIndicatorView() -> NSImageView {
149-
let view = NSImageView()
150-
view.imageScaling = .scaleNone
151-
view.imageAlignment = .alignCenter
152-
view.contentTintColor = .secondaryLabelColor
153-
view.translatesAutoresizingMaskIntoConstraints = true
154-
return view
181+
static func isInResizeZone(
182+
point: NSPoint,
183+
columnEdges: [CGFloat],
184+
zoneWidth: CGFloat = SortableHeaderView.resizeZoneWidth
185+
) -> Bool {
186+
columnEdges.contains { abs(point.x - $0) <= zoneWidth }
155187
}
156188

157-
private static let clickDragThreshold: CGFloat = 4
158-
159-
private var pendingClickStartLocation: NSPoint?
160-
private var dragOccurredDuringClick = false
189+
private func isInResizeZone(_ point: NSPoint, in tableView: NSTableView) -> Bool {
190+
let edges = tableView.tableColumns.enumerated().compactMap { index, column -> CGFloat? in
191+
guard column.resizingMask.contains(.userResizingMask) else { return nil }
192+
return headerRect(ofColumn: index).maxX
193+
}
194+
return Self.isInResizeZone(point: point, columnEdges: edges)
195+
}
161196

162197
override func mouseDragged(with event: NSEvent) {
163198
if let start = pendingClickStartLocation {
@@ -178,6 +213,11 @@ final class SortableHeaderView: NSTableHeaderView {
178213
}
179214

180215
let pointInHeader = convert(event.locationInWindow, from: nil)
216+
if isInResizeZone(pointInHeader, in: tableView) {
217+
super.mouseDown(with: event)
218+
return
219+
}
220+
181221
let columnIndex = column(at: pointInHeader)
182222
guard columnIndex >= 0, columnIndex < tableView.numberOfColumns else {
183223
super.mouseDown(with: event)

TablePro/Views/Results/SuppressedSortIndicatorCell.swift

Lines changed: 0 additions & 24 deletions
This file was deleted.

0 commit comments

Comments
 (0)