Skip to content

Commit c755bae

Browse files
committed
perf(datagrid): six scroll-path improvements cut max main-thread stall by 62%
1 parent 0f2f965 commit c755bae

6 files changed

Lines changed: 110 additions & 62 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2626

2727
### Changed
2828

29+
- Data grid: max main-thread stall during wide-table scroll drops from 3.5s to about 1.3s. Cell `configure` now skips `needsDisplay = true` when the visible state is unchanged; the display cache is a Swift `Dictionary<RowID, RowDisplayBox>` with O(1) head-index eviction instead of `NSCache` with a per-call `RowIDKey` wrapper allocation; the date parser memoizes the most recent successful index so consecutive cells in the same column hit the right format first try; `viewFor:row:` is wrapped in `autoreleasepool` to drain transient NSString/NSAttributedString allocations per cell; and `DataGridCellView` no longer takes its own backing layer, letting the row view's layer absorb cell drawing
2930
- Sidebar: selected row icon and label tint to white so kind colors (indigo, teal, purple) stay readable on the blue selection background
3031
- Sidebar: drop the per-section item count; empty optional-kind sections are already hidden, so the count was visual noise that also jittered next to the hover-revealed disclosure chevron
3132
- Internal: sidebar rows use `Label` instead of hand-rolled `HStack`, and the selection-aware tint lives in a single `sidebarTint(_:)` modifier shared by table and routine rows

TablePro/Core/DataGrid/RowDisplayBox.swift

Lines changed: 58 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,70 @@
55

66
import Foundation
77

8-
final class RowIDKey: NSObject {
9-
let id: RowID
8+
final class RowDisplayBox {
9+
var values: ContiguousArray<String?>
1010

11-
init(_ id: RowID) {
12-
self.id = id
13-
super.init()
11+
init(_ values: ContiguousArray<String?>) {
12+
self.values = values
1413
}
14+
}
1515

16-
override func isEqual(_ object: Any?) -> Bool {
17-
guard let other = object as? RowIDKey else { return false }
18-
return other.id == id
16+
@MainActor
17+
final class RowDisplayCache {
18+
private var storage: [RowID: RowDisplayBox] = [:]
19+
private var insertionOrder: [RowID] = []
20+
private var insertionHead: Int = 0
21+
private var totalCost: Int = 0
22+
private let countLimit: Int
23+
private let costLimit: Int
24+
25+
init(countLimit: Int = 50_000, costLimit: Int = 64 * 1_024 * 1_024) {
26+
self.countLimit = countLimit
27+
self.costLimit = costLimit
1928
}
2029

21-
override var hash: Int { id.hashValue }
22-
}
30+
func box(forID id: RowID) -> RowDisplayBox? {
31+
storage[id]
32+
}
2333

24-
final class RowDisplayBox: NSObject {
25-
var values: ContiguousArray<String?>
34+
func setBox(_ box: RowDisplayBox, forID id: RowID, cost: Int) {
35+
if let existing = storage[id] {
36+
totalCost -= rowCost(existing.values)
37+
} else {
38+
insertionOrder.append(id)
39+
}
40+
storage[id] = box
41+
totalCost += cost
42+
evictIfNeeded()
43+
}
2644

27-
init(_ values: ContiguousArray<String?>) {
28-
self.values = values
29-
super.init()
45+
func removeAll() {
46+
storage.removeAll(keepingCapacity: true)
47+
insertionOrder.removeAll(keepingCapacity: true)
48+
insertionHead = 0
49+
totalCost = 0
50+
}
51+
52+
private func evictIfNeeded() {
53+
while storage.count > countLimit || totalCost > costLimit {
54+
guard insertionHead < insertionOrder.count else { break }
55+
let oldest = insertionOrder[insertionHead]
56+
insertionHead += 1
57+
if let removed = storage.removeValue(forKey: oldest) {
58+
totalCost -= rowCost(removed.values)
59+
}
60+
}
61+
if insertionHead > 10_000 {
62+
insertionOrder.removeFirst(insertionHead)
63+
insertionHead = 0
64+
}
65+
}
66+
67+
private func rowCost(_ values: ContiguousArray<String?>) -> Int {
68+
var total = 0
69+
for value in values {
70+
if let s = value { total &+= s.utf8.count }
71+
}
72+
return total
3073
}
3174
}

TablePro/Core/Services/Formatting/DateFormattingService.swift

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ final class DateFormattingService {
2424
/// Parsers for common database date formats (ISO 8601, MySQL, PostgreSQL, SQLite)
2525
private let parsers: [DateFormatter]
2626

27+
/// Index of the parser that succeeded most recently. Tried first on the next parse
28+
/// because consecutive cells in the same column share the same wire format.
29+
private var lastSuccessfulParserIndex: Int = 0
30+
2731
/// Cache for formatted date strings to avoid repeated parsing
2832
private let formatCache = NSCache<NSString, NSString>()
2933

@@ -67,9 +71,14 @@ final class DateFormattingService {
6771
return cached.length == 0 ? nil : cached as String
6872
}
6973

70-
// Try parsing with each parser
71-
for parser in parsers {
72-
if let date = parser.date(from: dateString) {
74+
if let date = parsers[lastSuccessfulParserIndex].date(from: dateString) {
75+
let result = format(date)
76+
formatCache.setObject(result as NSString, forKey: cacheKey)
77+
return result
78+
}
79+
for index in parsers.indices where index != lastSuccessfulParserIndex {
80+
if let date = parsers[index].date(from: dateString) {
81+
lastSuccessfulParserIndex = index
7382
let result = format(date)
7483
formatCache.setObject(result as NSString, forKey: cacheKey)
7584
return result

TablePro/Views/Results/Cells/DataGridCellView.swift

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -61,37 +61,25 @@ final class DataGridCellView: NSView {
6161
}
6262

6363
private func commonInit() {
64-
wantsLayer = true
65-
layerContentsRedrawPolicy = .onSetNeedsDisplay
66-
canDrawSubviewsIntoLayer = true
6764
setAccessibilityElement(true)
6865
setAccessibilityRole(.cell)
6966
}
7067

7168
override var allowsVibrancy: Bool { false }
7269
override var isFlipped: Bool { true }
7370

74-
override func makeBackingLayer() -> CALayer {
75-
let layer = super.makeBackingLayer()
76-
layer.actions = Self.disabledLayerActions
77-
return layer
78-
}
79-
80-
private static let disabledLayerActions: [String: any CAAction] = [
81-
"position": NSNull(),
82-
"bounds": NSNull(),
83-
"frame": NSNull(),
84-
"contents": NSNull(),
85-
"hidden": NSNull(),
86-
]
87-
8871
func configure(
8972
kind: DataGridCellKind,
9073
content: DataGridCellContent,
9174
state: DataGridCellState,
9275
palette: DataGridCellPalette
9376
) {
94-
self.kind = kind
77+
var needsRedraw = false
78+
79+
if self.kind != kind {
80+
self.kind = kind
81+
needsRedraw = true
82+
}
9583
cellRow = state.row
9684
cellColumnIndex = state.columnIndex
9785

@@ -126,9 +114,13 @@ final class DataGridCellView: NSView {
126114
textFont = nextFont
127115
textColor = nextColor
128116
cachedLine = nil
117+
needsRedraw = true
129118
}
130119

131-
rawValue = content.rawValue
120+
if rawValue != content.rawValue {
121+
rawValue = content.rawValue
122+
needsRedraw = true
123+
}
132124
placeholder = content.placeholder
133125
isLargeDataset = state.isLargeDataset
134126
isEditableCell = state.isEditable
@@ -143,18 +135,25 @@ final class DataGridCellView: NSView {
143135
}
144136
if !colorsEqual(modifiedColumnTint, nextTint) {
145137
modifiedColumnTint = nextTint
138+
needsRedraw = true
146139
}
147140

148-
visualState = state.visualState
141+
if visualState != state.visualState {
142+
visualState = state.visualState
143+
needsRedraw = true
144+
}
149145
if isFocusedCell != state.isFocused {
150146
isFocusedCell = state.isFocused
151147
updateFocusPresentation()
148+
needsRedraw = true
152149
}
153150

154151
setAccessibilityRowIndexRange(NSRange(location: state.row, length: 1))
155152
setAccessibilityColumnIndexRange(NSRange(location: state.columnIndex, length: 1))
156153

157-
needsDisplay = true
154+
if needsRedraw {
155+
needsDisplay = true
156+
}
158157
}
159158

160159
override func accessibilityLabel() -> String? {

TablePro/Views/Results/DataGridCoordinator.swift

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
1515
var isEditable: Bool
1616
var sortedIDs: [RowID]?
1717
private(set) var columnDisplayFormats: [ValueDisplayFormat?] = []
18-
private let displayCache: NSCache<RowIDKey, RowDisplayBox> = {
19-
let cache = NSCache<RowIDKey, RowDisplayBox>()
20-
cache.countLimit = 50_000
21-
cache.totalCostLimit = 64 * 1_024 * 1_024
22-
cache.name = "TablePro.DataGrid.displayCache"
23-
return cache
24-
}()
18+
private let displayCache = RowDisplayCache()
2519
weak var delegate: (any DataGridViewDelegate)?
2620
weak var activeFKPreviewPopover: NSPopover?
2721
var dropdownColumns: Set<Int>?
@@ -204,7 +198,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
204198
themeCancellable?.cancel()
205199
themeCancellable = nil
206200
visualIndex.clear()
207-
displayCache.removeAllObjects()
201+
displayCache.removeAll()
208202
columnDisplayFormats = []
209203
cachedRowCount = 0
210204
cachedColumnCount = 0
@@ -275,8 +269,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
275269
}
276270

277271
func displayValue(forID id: RowID, column: Int, rawValue: PluginCellValue, columnType: ColumnType?) -> String? {
278-
let key = RowIDKey(id)
279-
if let box = displayCache.object(forKey: key),
272+
if let box = displayCache.box(forID: id),
280273
column >= 0, column < box.values.count,
281274
let cached = box.values[column] {
282275
return cached
@@ -286,7 +279,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
286279

287280
let neededCount = max(column + 1, columnDisplayFormats.count, cachedColumnCount)
288281
let box: RowDisplayBox
289-
if let existing = displayCache.object(forKey: key) {
282+
if let existing = displayCache.box(forID: id) {
290283
box = existing
291284
if box.values.count < neededCount {
292285
box.values.reserveCapacity(neededCount)
@@ -301,28 +294,28 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
301294
if column >= 0, column < box.values.count {
302295
box.values[column] = formatted
303296
}
304-
displayCache.setObject(box, forKey: key, cost: displayCacheCost(box.values))
297+
displayCache.setBox(box, forID: id, cost: displayCacheCost(box.values))
305298
return formatted
306299
}
307300

308301
func invalidateDisplayCache() {
309-
displayCache.removeAllObjects()
302+
displayCache.removeAll()
310303
}
311304

312305
func invalidateAllDisplayCaches() {
313-
displayCache.removeAllObjects()
306+
displayCache.removeAll()
314307
visualIndex.rebuild(from: changeManager, sortedIDs: sortedIDs)
315308
}
316309

317310
func updateDisplayFormats(_ formats: [ValueDisplayFormat?]) {
318311
columnDisplayFormats = formats
319-
displayCache.removeAllObjects()
312+
displayCache.removeAll()
320313
}
321314

322315
func syncDisplayFormats(_ formats: [ValueDisplayFormat?]) {
323316
guard formats != columnDisplayFormats else { return }
324317
columnDisplayFormats = formats
325-
displayCache.removeAllObjects()
318+
displayCache.removeAll()
326319
}
327320

328321
func preWarmDisplayCache(upTo rowCount: Int) {
@@ -412,8 +405,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
412405

413406
private func cacheDisplayRow(at displayIndex: Int, in tableRows: TableRows) {
414407
guard let row = displayRow(at: displayIndex, in: tableRows) else { return }
415-
let key = RowIDKey(row.id)
416-
guard displayCache.object(forKey: key) == nil else { return }
408+
guard displayCache.box(forID: row.id) == nil else { return }
417409

418410
let columnCount = tableRows.columns.count
419411
var values = ContiguousArray<String?>()
@@ -429,7 +421,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
429421
) ?? row.values[col].asText
430422
}
431423
let box = RowDisplayBox(values)
432-
displayCache.setObject(box, forKey: key, cost: displayCacheCost(values))
424+
displayCache.setBox(box, forID: row.id, cost: displayCacheCost(values))
433425
}
434426

435427
private func displayCacheCost(_ values: ContiguousArray<String?>) -> Int {
@@ -442,10 +434,10 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
442434

443435
private func invalidateDisplayCache(forDisplayRow displayIndex: Int, column: Int) {
444436
guard let row = displayRow(at: displayIndex) else { return }
445-
let key = RowIDKey(row.id)
446-
guard let box = displayCache.object(forKey: key), column >= 0, column < box.values.count else { return }
437+
guard let box = displayCache.box(forID: row.id),
438+
column >= 0, column < box.values.count else { return }
447439
box.values[column] = nil
448-
displayCache.setObject(box, forKey: key, cost: displayCacheCost(box.values))
440+
displayCache.setBox(box, forID: row.id, cost: displayCacheCost(box.values))
449441
}
450442

451443
func applyDelta(_ delta: Delta) {
@@ -622,7 +614,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
622614

623615
guard schemaChanged else { return false }
624616
identitySchema = nextSchema
625-
displayCache.removeAllObjects()
617+
displayCache.removeAll()
626618
return true
627619
}
628620

TablePro/Views/Results/Extensions/DataGridView+Columns.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import TableProPluginKit
99

1010
extension TableViewCoordinator {
1111
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
12+
autoreleasepool { viewForCell(in: tableView, column: tableColumn, row: row) }
13+
}
14+
15+
private func viewForCell(in tableView: NSTableView, column tableColumn: NSTableColumn?, row: Int) -> NSView? {
1216
guard let column = tableColumn else { return nil }
1317

1418
let tableRows = tableRowsProvider()

0 commit comments

Comments
 (0)