Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
60537f6
perf(datagrid): add OSLog signposts to measure cell rendering hot path
datlechin Apr 30, 2026
cd7e2c0
refactor(datagrid): add typed cell hierarchy + registry foundation
datlechin Apr 30, 2026
0d3468c
refactor(datagrid): persistent column pool with slot-based identifiers
datlechin Apr 30, 2026
bd34421
refactor(datagrid): wire registry + pool, delete mega-cell
datlechin Apr 30, 2026
313d94e
fix(datagrid): set cell text-field delegate once, drop redundant per-…
datlechin Apr 30, 2026
bc95c86
fix(datagrid): filter saved column order to current schema before mov…
datlechin Apr 30, 2026
a80f6d7
fix(datagrid): reset slot order on reconcile, skip idempotent header …
datlechin Apr 30, 2026
1d9074b
fix(datagrid): attach columns in saved order on first growth, elimina…
datlechin Apr 30, 2026
ea2ee6e
fix(datagrid): detect column schema change via names not slot identif…
datlechin Apr 30, 2026
6f92de0
fix(datagrid): suppress moveColumn animation during reconcile to prev…
datlechin Apr 30, 2026
27e9ae8
test(datagrid): add DataGridColumnPool and DataGridCellRegistry tests
datlechin Apr 30, 2026
36c04cf
docs(datagrid): drop perf instrumentation, add Phase 2 design doc and…
datlechin Apr 30, 2026
d25115d
docs(datagrid): remove Phase 2 design doc
datlechin Apr 30, 2026
35d4406
fix(datagrid): always reconcile columns when data present, drop stale…
datlechin Apr 30, 2026
2414547
fix(datagrid): scope move animation to moveColumn calls + add pool te…
datlechin Apr 30, 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- DataGrid columns and cells refactored to use a persistent column pool and typed cell view hierarchy. CPU usage on table switch reduced significantly through proper NSTableView reuse pool retention.
- 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.
- `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.
- 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.
Expand Down
47 changes: 31 additions & 16 deletions TablePro/Models/UI/ColumnIdentitySchema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,33 @@ import AppKit

struct ColumnIdentitySchema: Equatable {
static let rowNumberIdentifier = NSUserInterfaceItemIdentifier("__rowNumber__")
static let dataColumnPrefix = "dataColumn-"

let identifiers: [NSUserInterfaceItemIdentifier]
let isNameBased: Bool
let columnNames: [String]

private let indexByRawIdentifier: [String: Int]
private let slotByColumnName: [String: Int]

init(columns: [String]) {
let canUseNames = Set(columns).count == columns.count
&& !columns.contains(Self.rowNumberIdentifier.rawValue)

if canUseNames {
self.identifiers = columns.map { NSUserInterfaceItemIdentifier($0) }
self.isNameBased = true
} else {
self.identifiers = columns.indices.map {
NSUserInterfaceItemIdentifier("col_\($0)")
}
self.isNameBased = false
self.columnNames = columns
self.identifiers = columns.indices.map {
NSUserInterfaceItemIdentifier("\(Self.dataColumnPrefix)\($0)")
}

var map: [String: Int] = [:]
map.reserveCapacity(self.identifiers.count)
var rawMap: [String: Int] = [:]
rawMap.reserveCapacity(self.identifiers.count)
for (index, identifier) in self.identifiers.enumerated() {
map[identifier.rawValue] = index
rawMap[identifier.rawValue] = index
}
self.indexByRawIdentifier = rawMap

var nameMap: [String: Int] = [:]
nameMap.reserveCapacity(columns.count)
for (index, name) in columns.enumerated() {
nameMap[name] = index
}
self.indexByRawIdentifier = map
self.slotByColumnName = nameMap
}

static let empty = ColumnIdentitySchema(columns: [])
Expand All @@ -44,4 +46,17 @@ struct ColumnIdentitySchema: Equatable {
func dataIndex(from identifier: NSUserInterfaceItemIdentifier) -> Int? {
indexByRawIdentifier[identifier.rawValue]
}

func columnName(for dataIndex: Int) -> String? {
guard dataIndex >= 0, dataIndex < columnNames.count else { return nil }
return columnNames[dataIndex]
}

func dataIndex(forColumnName name: String) -> Int? {
slotByColumnName[name]
}

static func slotIdentifier(_ slot: Int) -> NSUserInterfaceItemIdentifier {
NSUserInterfaceItemIdentifier("\(dataColumnPrefix)\(slot)")
}
}
55 changes: 55 additions & 0 deletions TablePro/Views/Results/Cells/AccessoryButtons.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//
// AccessoryButtons.swift
// TablePro
//

import AppKit

@MainActor
final class FKArrowButton: NSButton {
var fkRow: Int = -1
var fkColumnIndex: Int = -1
}

@MainActor
final class CellChevronButton: NSButton {
var cellRow: Int = -1
var cellColumnIndex: Int = -1
}

@MainActor
enum AccessoryButtonFactory {
static func makeFKArrowButton() -> FKArrowButton {
let button = FKArrowButton()
button.bezelStyle = .inline
button.isBordered = false
button.image = NSImage(
systemSymbolName: "arrow.right.circle.fill",
accessibilityDescription: String(localized: "Navigate to referenced row")
)
button.contentTintColor = .tertiaryLabelColor
button.translatesAutoresizingMaskIntoConstraints = false
button.setContentHuggingPriority(.required, for: .horizontal)
button.setContentCompressionResistancePriority(.required, for: .horizontal)
button.imageScaling = .scaleProportionallyDown
button.isHidden = true
return button
}

static func makeChevronButton() -> CellChevronButton {
let chevron = CellChevronButton()
chevron.bezelStyle = .inline
chevron.isBordered = false
chevron.image = NSImage(
systemSymbolName: "chevron.up.chevron.down",
accessibilityDescription: String(localized: "Open editor")
)
chevron.contentTintColor = .tertiaryLabelColor
chevron.translatesAutoresizingMaskIntoConstraints = false
chevron.setContentHuggingPriority(.required, for: .horizontal)
chevron.setContentCompressionResistancePriority(.required, for: .horizontal)
chevron.imageScaling = .scaleProportionallyDown
chevron.isHidden = true
return chevron
}
}
223 changes: 223 additions & 0 deletions TablePro/Views/Results/Cells/DataGridBaseCellView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
//
// DataGridBaseCellView.swift
// TablePro
//

import AppKit
import QuartzCore

class DataGridBaseCellView: NSTableCellView {
class var reuseIdentifier: NSUserInterfaceItemIdentifier {
fatalError("subclass must override reuseIdentifier")
}

let cellTextField: CellTextField
weak var accessoryDelegate: DataGridCellAccessoryDelegate?
var nullDisplayString: String = ""
var cellRow: Int = -1
var cellColumnIndex: Int = -1

private var textFieldTrailingConstraint: NSLayoutConstraint!

var changeBackgroundColor: NSColor? {
didSet {
if let color = changeBackgroundColor {
backgroundView.layer?.backgroundColor = color.cgColor
backgroundView.isHidden = (backgroundStyle == .emphasized)
} else {
backgroundView.layer?.backgroundColor = nil
backgroundView.isHidden = true
}
}
}

var isFocusedCell: Bool = false {
didSet {
guard oldValue != isFocusedCell else { return }
updateFocusRing()
}
}

private(set) lazy var backgroundView: NSView = {
let view = NSView()
view.wantsLayer = true
view.translatesAutoresizingMaskIntoConstraints = false
addSubview(view, positioned: .below, relativeTo: subviews.first)
NSLayoutConstraint.activate([
view.leadingAnchor.constraint(equalTo: leadingAnchor),
view.trailingAnchor.constraint(equalTo: trailingAnchor),
view.topAnchor.constraint(equalTo: topAnchor),
view.bottomAnchor.constraint(equalTo: bottomAnchor),
])
view.isHidden = true
return view
}()

required override init(frame frameRect: NSRect) {
cellTextField = Self.makeTextField()
super.init(frame: frameRect)
commonInit()
}

required init?(coder: NSCoder) {
cellTextField = Self.makeTextField()
super.init(coder: coder)
commonInit()
}

private static func makeTextField() -> CellTextField {
let field = CellTextField()
field.font = ThemeEngine.shared.dataGridFonts.regular
field.drawsBackground = false
field.isBordered = false
field.focusRingType = .none
field.lineBreakMode = .byTruncatingTail
field.maximumNumberOfLines = 1
field.cell?.truncatesLastVisibleLine = true
field.cell?.usesSingleLineMode = true
field.translatesAutoresizingMaskIntoConstraints = false
return field
}

private func commonInit() {
wantsLayer = true
textField = cellTextField
addSubview(cellTextField)

textFieldTrailingConstraint = cellTextField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4)

NSLayoutConstraint.activate([
cellTextField.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4),
textFieldTrailingConstraint,
cellTextField.centerYAnchor.constraint(equalTo: centerYAnchor),
])

installAccessory()
}

func configure(content: DataGridCellContent, state: DataGridCellState) {
cellRow = state.row
cellColumnIndex = state.columnIndex

applyContent(content, isLargeDataset: state.isLargeDataset)
applyVisualState(state)

cellTextField.isEditable = state.isEditable && !state.visualState.isDeleted

let newInset = textFieldTrailingInset(for: content, state: state)
if textFieldTrailingConstraint.constant != newInset {
textFieldTrailingConstraint.constant = newInset
}

updateAccessoryVisibility(content: content, state: state)

cellTextField.setAccessibilityLabel(content.accessibilityLabel)
setAccessibilityRowIndexRange(NSRange(location: state.row, length: 1))
setAccessibilityColumnIndexRange(NSRange(location: state.columnIndex, length: 1))
}

func installAccessory() {}

func updateAccessoryVisibility(content: DataGridCellContent, state: DataGridCellState) {}

func textFieldTrailingInset(for content: DataGridCellContent, state: DataGridCellState) -> CGFloat {
-4
}

private func applyContent(_ content: DataGridCellContent, isLargeDataset: Bool) {
cellTextField.placeholderString = nil

switch content.placeholder {
case .none:
cellTextField.stringValue = content.displayText
cellTextField.originalValue = content.rawValue
cellTextField.font = ThemeEngine.shared.dataGridFonts.regular
cellTextField.tag = DataGridFontVariant.regular
cellTextField.textColor = .labelColor

case .null:
cellTextField.stringValue = ""
cellTextField.originalValue = nil
cellTextField.font = ThemeEngine.shared.dataGridFonts.italic
cellTextField.tag = DataGridFontVariant.italic
cellTextField.textColor = .secondaryLabelColor
if !isLargeDataset {
cellTextField.placeholderString = nullDisplayString
}

case .empty:
cellTextField.stringValue = ""
cellTextField.originalValue = nil
cellTextField.font = ThemeEngine.shared.dataGridFonts.italic
cellTextField.tag = DataGridFontVariant.italic
cellTextField.textColor = .secondaryLabelColor
if !isLargeDataset {
cellTextField.placeholderString = String(localized: "Empty")
}

case .defaultMarker:
cellTextField.stringValue = ""
cellTextField.originalValue = nil
cellTextField.font = ThemeEngine.shared.dataGridFonts.medium
cellTextField.tag = DataGridFontVariant.medium
cellTextField.textColor = .systemBlue
if !isLargeDataset {
cellTextField.placeholderString = String(localized: "DEFAULT")
}
}
}

private func applyVisualState(_ state: DataGridCellState) {
CATransaction.begin()
CATransaction.setDisableActions(true)

if state.visualState.isDeleted {
changeBackgroundColor = ThemeEngine.shared.colors.dataGrid.deleted
} else if state.visualState.isInserted {
changeBackgroundColor = ThemeEngine.shared.colors.dataGrid.inserted
} else if state.visualState.modifiedColumns.contains(state.columnIndex) {
changeBackgroundColor = ThemeEngine.shared.colors.dataGrid.modified
} else {
changeBackgroundColor = nil
}

isFocusedCell = state.isFocused

CATransaction.commit()
}

override var backgroundStyle: NSView.BackgroundStyle {
didSet {
backgroundView.isHidden = (backgroundStyle == .emphasized) || (changeBackgroundColor == nil)
if isFocusedCell { updateFocusRing() }
}
}

override var focusRingMaskBounds: NSRect { bounds }

override func drawFocusRingMask() {
NSBezierPath(rect: bounds).fill()
}

override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
guard isFocusedCell, backgroundStyle != .emphasized else { return }
NSGraphicsContext.saveGraphicsState()
NSFocusRingPlacement.only.set()
drawFocusRingMask()
NSGraphicsContext.restoreGraphicsState()
}

override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
if isFocusedCell {
needsDisplay = true
}
}

private func updateFocusRing() {
focusRingType = isFocusedCell ? .exterior : .none
noteFocusRingMaskChanged()
needsDisplay = true
}
}
12 changes: 12 additions & 0 deletions TablePro/Views/Results/Cells/DataGridBlobCellView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//
// DataGridBlobCellView.swift
// TablePro
//

import AppKit

final class DataGridBlobCellView: DataGridChevronCellView {
override class var reuseIdentifier: NSUserInterfaceItemIdentifier {
NSUserInterfaceItemIdentifier("dataCell.blob")
}
}
12 changes: 12 additions & 0 deletions TablePro/Views/Results/Cells/DataGridBooleanCellView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//
// DataGridBooleanCellView.swift
// TablePro
//

import AppKit

final class DataGridBooleanCellView: DataGridChevronCellView {
override class var reuseIdentifier: NSUserInterfaceItemIdentifier {
NSUserInterfaceItemIdentifier("dataCell.boolean")
}
}
12 changes: 12 additions & 0 deletions TablePro/Views/Results/Cells/DataGridCellAccessoryDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//
// DataGridCellAccessoryDelegate.swift
// TablePro
//

import Foundation

@MainActor
protocol DataGridCellAccessoryDelegate: AnyObject {
func dataGridCellDidClickFKArrow(row: Int, columnIndex: Int)
func dataGridCellDidClickChevron(row: Int, columnIndex: Int)
}
Loading
Loading