Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- 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
- Sidebar: selected row icon and label tint to white so kind colors (indigo, teal, purple) stay readable on the blue selection background
- 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
- 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
Expand Down
73 changes: 58 additions & 15 deletions TablePro/Core/DataGrid/RowDisplayBox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,70 @@

import Foundation

final class RowIDKey: NSObject {
let id: RowID
final class RowDisplayBox {
var values: ContiguousArray<String?>

init(_ id: RowID) {
self.id = id
super.init()
init(_ values: ContiguousArray<String?>) {
self.values = values
}
}

override func isEqual(_ object: Any?) -> Bool {
guard let other = object as? RowIDKey else { return false }
return other.id == id
@MainActor
final class RowDisplayCache {
private var storage: [RowID: RowDisplayBox] = [:]
private var insertionOrder: [RowID] = []
private var insertionHead: Int = 0
private var totalCost: Int = 0
private let countLimit: Int
private let costLimit: Int

init(countLimit: Int = 50_000, costLimit: Int = 64 * 1_024 * 1_024) {
self.countLimit = countLimit
self.costLimit = costLimit
}

override var hash: Int { id.hashValue }
}
func box(forID id: RowID) -> RowDisplayBox? {
storage[id]
}

final class RowDisplayBox: NSObject {
var values: ContiguousArray<String?>
func setBox(_ box: RowDisplayBox, forID id: RowID, cost: Int) {
if let existing = storage[id] {
totalCost -= rowCost(existing.values)
} else {
insertionOrder.append(id)
}
storage[id] = box
totalCost += cost
evictIfNeeded()
}

init(_ values: ContiguousArray<String?>) {
self.values = values
super.init()
func removeAll() {
storage.removeAll(keepingCapacity: true)
insertionOrder.removeAll(keepingCapacity: true)
insertionHead = 0
totalCost = 0
}

private func evictIfNeeded() {
while storage.count > countLimit || totalCost > costLimit {
guard insertionHead < insertionOrder.count else { break }
let oldest = insertionOrder[insertionHead]
insertionHead += 1
if let removed = storage.removeValue(forKey: oldest) {
totalCost -= rowCost(removed.values)
}
}
if insertionHead > 10_000 {
insertionOrder.removeFirst(insertionHead)
insertionHead = 0
}
}

private func rowCost(_ values: ContiguousArray<String?>) -> Int {
var total = 0
for value in values {
if let s = value { total &+= s.utf8.count }
}
return total
}
}
15 changes: 12 additions & 3 deletions TablePro/Core/Services/Formatting/DateFormattingService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ final class DateFormattingService {
/// Parsers for common database date formats (ISO 8601, MySQL, PostgreSQL, SQLite)
private let parsers: [DateFormatter]

/// Index of the parser that succeeded most recently. Tried first on the next parse
/// because consecutive cells in the same column share the same wire format.
private var lastSuccessfulParserIndex: Int = 0

/// Cache for formatted date strings to avoid repeated parsing
private let formatCache = NSCache<NSString, NSString>()

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

// Try parsing with each parser
for parser in parsers {
if let date = parser.date(from: dateString) {
if let date = parsers[lastSuccessfulParserIndex].date(from: dateString) {
let result = format(date)
formatCache.setObject(result as NSString, forKey: cacheKey)
return result
}
for index in parsers.indices where index != lastSuccessfulParserIndex {
if let date = parsers[index].date(from: dateString) {
lastSuccessfulParserIndex = index
let result = format(date)
formatCache.setObject(result as NSString, forKey: cacheKey)
return result
Expand Down
41 changes: 20 additions & 21 deletions TablePro/Views/Results/Cells/DataGridCellView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,37 +61,25 @@ final class DataGridCellView: NSView {
}

private func commonInit() {
wantsLayer = true
layerContentsRedrawPolicy = .onSetNeedsDisplay
canDrawSubviewsIntoLayer = true
setAccessibilityElement(true)
setAccessibilityRole(.cell)
}

override var allowsVibrancy: Bool { false }
override var isFlipped: Bool { true }

override func makeBackingLayer() -> CALayer {
let layer = super.makeBackingLayer()
layer.actions = Self.disabledLayerActions
return layer
}

private static let disabledLayerActions: [String: any CAAction] = [
"position": NSNull(),
"bounds": NSNull(),
"frame": NSNull(),
"contents": NSNull(),
"hidden": NSNull(),
]

func configure(
kind: DataGridCellKind,
content: DataGridCellContent,
state: DataGridCellState,
palette: DataGridCellPalette
) {
self.kind = kind
var needsRedraw = false

if self.kind != kind {
self.kind = kind
needsRedraw = true
}
cellRow = state.row
cellColumnIndex = state.columnIndex

Expand Down Expand Up @@ -126,9 +114,13 @@ final class DataGridCellView: NSView {
textFont = nextFont
textColor = nextColor
cachedLine = nil
needsRedraw = true
}

rawValue = content.rawValue
if rawValue != content.rawValue {
rawValue = content.rawValue
needsRedraw = true
}
placeholder = content.placeholder
isLargeDataset = state.isLargeDataset
isEditableCell = state.isEditable
Expand All @@ -143,18 +135,25 @@ final class DataGridCellView: NSView {
}
if !colorsEqual(modifiedColumnTint, nextTint) {
modifiedColumnTint = nextTint
needsRedraw = true
}

visualState = state.visualState
if visualState != state.visualState {
visualState = state.visualState
needsRedraw = true
}
if isFocusedCell != state.isFocused {
isFocusedCell = state.isFocused
updateFocusPresentation()
needsRedraw = true
}

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

needsDisplay = true
if needsRedraw {
needsDisplay = true
}
}

override func accessibilityLabel() -> String? {
Expand Down
38 changes: 15 additions & 23 deletions TablePro/Views/Results/DataGridCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
var isEditable: Bool
var sortedIDs: [RowID]?
private(set) var columnDisplayFormats: [ValueDisplayFormat?] = []
private let displayCache: NSCache<RowIDKey, RowDisplayBox> = {
let cache = NSCache<RowIDKey, RowDisplayBox>()
cache.countLimit = 50_000
cache.totalCostLimit = 64 * 1_024 * 1_024
cache.name = "TablePro.DataGrid.displayCache"
return cache
}()
private let displayCache = RowDisplayCache()
weak var delegate: (any DataGridViewDelegate)?
weak var activeFKPreviewPopover: NSPopover?
var dropdownColumns: Set<Int>?
Expand Down Expand Up @@ -204,7 +198,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
themeCancellable?.cancel()
themeCancellable = nil
visualIndex.clear()
displayCache.removeAllObjects()
displayCache.removeAll()
columnDisplayFormats = []
cachedRowCount = 0
cachedColumnCount = 0
Expand Down Expand Up @@ -275,8 +269,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
}

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

let neededCount = max(column + 1, columnDisplayFormats.count, cachedColumnCount)
let box: RowDisplayBox
if let existing = displayCache.object(forKey: key) {
if let existing = displayCache.box(forID: id) {
box = existing
if box.values.count < neededCount {
box.values.reserveCapacity(neededCount)
Expand All @@ -301,28 +294,28 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
if column >= 0, column < box.values.count {
box.values[column] = formatted
}
displayCache.setObject(box, forKey: key, cost: displayCacheCost(box.values))
displayCache.setBox(box, forID: id, cost: displayCacheCost(box.values))
return formatted
}

func invalidateDisplayCache() {
displayCache.removeAllObjects()
displayCache.removeAll()
}

func invalidateAllDisplayCaches() {
displayCache.removeAllObjects()
displayCache.removeAll()
visualIndex.rebuild(from: changeManager, sortedIDs: sortedIDs)
}

func updateDisplayFormats(_ formats: [ValueDisplayFormat?]) {
columnDisplayFormats = formats
displayCache.removeAllObjects()
displayCache.removeAll()
}

func syncDisplayFormats(_ formats: [ValueDisplayFormat?]) {
guard formats != columnDisplayFormats else { return }
columnDisplayFormats = formats
displayCache.removeAllObjects()
displayCache.removeAll()
}

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

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

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

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

private func invalidateDisplayCache(forDisplayRow displayIndex: Int, column: Int) {
guard let row = displayRow(at: displayIndex) else { return }
let key = RowIDKey(row.id)
guard let box = displayCache.object(forKey: key), column >= 0, column < box.values.count else { return }
guard let box = displayCache.box(forID: row.id),
column >= 0, column < box.values.count else { return }
box.values[column] = nil
displayCache.setObject(box, forKey: key, cost: displayCacheCost(box.values))
displayCache.setBox(box, forID: row.id, cost: displayCacheCost(box.values))
}

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

guard schemaChanged else { return false }
identitySchema = nextSchema
displayCache.removeAllObjects()
displayCache.removeAll()
return true
}

Expand Down
4 changes: 4 additions & 0 deletions TablePro/Views/Results/Extensions/DataGridView+Columns.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import TableProPluginKit

extension TableViewCoordinator {
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
autoreleasepool { viewForCell(in: tableView, column: tableColumn, row: row) }
}

private func viewForCell(in tableView: NSTableView, column tableColumn: NSTableColumn?, row: Int) -> NSView? {
guard let column = tableColumn else { return nil }

let tableRows = tableRowsProvider()
Expand Down
Loading