diff --git a/CHANGELOG.md b/CHANGELOG.md index db65f71cd..d30807276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/TablePro/Models/UI/ColumnIdentitySchema.swift b/TablePro/Models/UI/ColumnIdentitySchema.swift index 7c55344be..62671ecfb 100644 --- a/TablePro/Models/UI/ColumnIdentitySchema.swift +++ b/TablePro/Models/UI/ColumnIdentitySchema.swift @@ -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: []) @@ -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)") + } } diff --git a/TablePro/Views/Results/Cells/AccessoryButtons.swift b/TablePro/Views/Results/Cells/AccessoryButtons.swift new file mode 100644 index 000000000..e40341cc7 --- /dev/null +++ b/TablePro/Views/Results/Cells/AccessoryButtons.swift @@ -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 + } +} diff --git a/TablePro/Views/Results/Cells/DataGridBaseCellView.swift b/TablePro/Views/Results/Cells/DataGridBaseCellView.swift new file mode 100644 index 000000000..3f5225b6f --- /dev/null +++ b/TablePro/Views/Results/Cells/DataGridBaseCellView.swift @@ -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 + } +} diff --git a/TablePro/Views/Results/Cells/DataGridBlobCellView.swift b/TablePro/Views/Results/Cells/DataGridBlobCellView.swift new file mode 100644 index 000000000..d4f93bf43 --- /dev/null +++ b/TablePro/Views/Results/Cells/DataGridBlobCellView.swift @@ -0,0 +1,12 @@ +// +// DataGridBlobCellView.swift +// TablePro +// + +import AppKit + +final class DataGridBlobCellView: DataGridChevronCellView { + override class var reuseIdentifier: NSUserInterfaceItemIdentifier { + NSUserInterfaceItemIdentifier("dataCell.blob") + } +} diff --git a/TablePro/Views/Results/Cells/DataGridBooleanCellView.swift b/TablePro/Views/Results/Cells/DataGridBooleanCellView.swift new file mode 100644 index 000000000..5bbe227ea --- /dev/null +++ b/TablePro/Views/Results/Cells/DataGridBooleanCellView.swift @@ -0,0 +1,12 @@ +// +// DataGridBooleanCellView.swift +// TablePro +// + +import AppKit + +final class DataGridBooleanCellView: DataGridChevronCellView { + override class var reuseIdentifier: NSUserInterfaceItemIdentifier { + NSUserInterfaceItemIdentifier("dataCell.boolean") + } +} diff --git a/TablePro/Views/Results/Cells/DataGridCellAccessoryDelegate.swift b/TablePro/Views/Results/Cells/DataGridCellAccessoryDelegate.swift new file mode 100644 index 000000000..cb700c25b --- /dev/null +++ b/TablePro/Views/Results/Cells/DataGridCellAccessoryDelegate.swift @@ -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) +} diff --git a/TablePro/Views/Results/Cells/DataGridCellContent.swift b/TablePro/Views/Results/Cells/DataGridCellContent.swift new file mode 100644 index 000000000..2b54b72b6 --- /dev/null +++ b/TablePro/Views/Results/Cells/DataGridCellContent.swift @@ -0,0 +1,28 @@ +// +// DataGridCellContent.swift +// TablePro +// + +import Foundation + +enum DataGridCellPlaceholder: Equatable { + case null + case empty + case defaultMarker +} + +struct DataGridCellContent { + let displayText: String + let rawValue: String? + let placeholder: DataGridCellPlaceholder? + let accessibilityLabel: String +} + +struct DataGridCellState { + let visualState: RowVisualState + let isFocused: Bool + let isEditable: Bool + let isLargeDataset: Bool + let row: Int + let columnIndex: Int +} diff --git a/TablePro/Views/Results/Cells/DataGridCellKind.swift b/TablePro/Views/Results/Cells/DataGridCellKind.swift new file mode 100644 index 000000000..e903394c2 --- /dev/null +++ b/TablePro/Views/Results/Cells/DataGridCellKind.swift @@ -0,0 +1,16 @@ +// +// DataGridCellKind.swift +// TablePro +// + +import Foundation + +enum DataGridCellKind: Equatable { + case text + case foreignKey + case dropdown + case boolean + case date + case json + case blob +} diff --git a/TablePro/Views/Results/Cells/DataGridCellRegistry.swift b/TablePro/Views/Results/Cells/DataGridCellRegistry.swift new file mode 100644 index 000000000..d90f75f98 --- /dev/null +++ b/TablePro/Views/Results/Cells/DataGridCellRegistry.swift @@ -0,0 +1,143 @@ +// +// DataGridCellRegistry.swift +// TablePro +// + +import AppKit +import Foundation + +@MainActor +final class DataGridCellRegistry { + weak var accessoryDelegate: DataGridCellAccessoryDelegate? + weak var textFieldDelegate: NSTextFieldDelegate? + + private(set) var nullDisplayString: String + private var settingsObserver: NSObjectProtocol? + + private let rowNumberCellIdentifier = NSUserInterfaceItemIdentifier("RowNumberCellView") + + init() { + nullDisplayString = AppSettingsManager.shared.dataGrid.nullDisplay + settingsObserver = NotificationCenter.default.addObserver( + forName: .dataGridSettingsDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.nullDisplayString = AppSettingsManager.shared.dataGrid.nullDisplay + } + } + } + + deinit { + if let observer = settingsObserver { + NotificationCenter.default.removeObserver(observer) + } + } + + func resolveKind( + columnIndex: Int, + columnType: ColumnType?, + isFKColumn: Bool, + isDropdownColumn: Bool + ) -> DataGridCellKind { + if isFKColumn { return .foreignKey } + if isDropdownColumn { return .dropdown } + if let type = columnType { + if type.isBooleanType { return .boolean } + if type.isDateType { return .date } + if type.isJsonType { return .json } + if type.isBlobType { return .blob } + } + return .text + } + + func dequeueCell(of kind: DataGridCellKind, in tableView: NSTableView) -> DataGridBaseCellView { + let identifier: NSUserInterfaceItemIdentifier + let cellType: DataGridBaseCellView.Type + + switch kind { + case .text: + identifier = DataGridTextCellView.reuseIdentifier + cellType = DataGridTextCellView.self + case .foreignKey: + identifier = DataGridForeignKeyCellView.reuseIdentifier + cellType = DataGridForeignKeyCellView.self + case .dropdown: + identifier = DataGridDropdownCellView.reuseIdentifier + cellType = DataGridDropdownCellView.self + case .boolean: + identifier = DataGridBooleanCellView.reuseIdentifier + cellType = DataGridBooleanCellView.self + case .date: + identifier = DataGridDateCellView.reuseIdentifier + cellType = DataGridDateCellView.self + case .json: + identifier = DataGridJsonCellView.reuseIdentifier + cellType = DataGridJsonCellView.self + case .blob: + identifier = DataGridBlobCellView.reuseIdentifier + cellType = DataGridBlobCellView.self + } + + if let reused = tableView.makeView(withIdentifier: identifier, owner: nil) as? DataGridBaseCellView { + reused.nullDisplayString = nullDisplayString + return reused + } + + let cell = cellType.init(frame: .zero) + cell.identifier = identifier + cell.accessoryDelegate = accessoryDelegate + cell.cellTextField.delegate = textFieldDelegate + cell.nullDisplayString = nullDisplayString + return cell + } + + func makeRowNumberCell( + in tableView: NSTableView, + row: Int, + cachedRowCount: Int, + visualState: RowVisualState + ) -> NSView { + let cellView: NSTableCellView + let cell: NSTextField + + if let reused = tableView.makeView(withIdentifier: rowNumberCellIdentifier, owner: nil) as? NSTableCellView, + let textField = reused.textField { + cellView = reused + cell = textField + cell.font = ThemeEngine.shared.dataGridFonts.rowNumber + } else { + cellView = NSTableCellView() + cellView.identifier = rowNumberCellIdentifier + + cell = NSTextField(labelWithString: "") + cell.alignment = .right + cell.font = ThemeEngine.shared.dataGridFonts.rowNumber + cell.tag = DataGridFontVariant.rowNumber + cell.textColor = .secondaryLabelColor + cell.translatesAutoresizingMaskIntoConstraints = false + + cellView.textField = cell + cellView.addSubview(cell) + + NSLayoutConstraint.activate([ + cell.leadingAnchor.constraint(equalTo: cellView.leadingAnchor, constant: 4), + cell.trailingAnchor.constraint(equalTo: cellView.trailingAnchor, constant: -4), + cell.centerYAnchor.constraint(equalTo: cellView.centerYAnchor), + ]) + } + + guard row >= 0 && row < cachedRowCount else { + cell.stringValue = "" + return cellView + } + + cell.stringValue = "\(row + 1)" + cell.textColor = visualState.isDeleted ? ThemeEngine.shared.colors.dataGrid.deletedText : .secondaryLabelColor + cellView.setAccessibilityLabel(String(format: String(localized: "Row %d"), row + 1)) + cellView.setAccessibilityRowIndexRange(NSRange(location: row, length: 1)) + + return cellView + } +} diff --git a/TablePro/Views/Results/Cells/DataGridChevronCellView.swift b/TablePro/Views/Results/Cells/DataGridChevronCellView.swift new file mode 100644 index 000000000..0bf8947cb --- /dev/null +++ b/TablePro/Views/Results/Cells/DataGridChevronCellView.swift @@ -0,0 +1,44 @@ +// +// DataGridChevronCellView.swift +// TablePro +// + +import AppKit + +class DataGridChevronCellView: DataGridBaseCellView { + private lazy var chevronButton: CellChevronButton = AccessoryButtonFactory.makeChevronButton() + + override func installAccessory() { + addSubview(chevronButton) + NSLayoutConstraint.activate([ + chevronButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4), + chevronButton.centerYAnchor.constraint(equalTo: centerYAnchor), + chevronButton.widthAnchor.constraint(equalToConstant: 10), + chevronButton.heightAnchor.constraint(equalToConstant: 12), + ]) + chevronButton.target = self + chevronButton.action = #selector(handleChevronClick(_:)) + } + + override func updateAccessoryVisibility(content: DataGridCellContent, state: DataGridCellState) { + let show = state.isEditable && !state.visualState.isDeleted + chevronButton.isHidden = !show + if show { + chevronButton.cellRow = state.row + chevronButton.cellColumnIndex = state.columnIndex + } else { + chevronButton.cellRow = -1 + chevronButton.cellColumnIndex = -1 + } + } + + override func textFieldTrailingInset(for content: DataGridCellContent, state: DataGridCellState) -> CGFloat { + let show = state.isEditable && !state.visualState.isDeleted + return show ? -18 : -4 + } + + @objc + private func handleChevronClick(_ sender: CellChevronButton) { + accessoryDelegate?.dataGridCellDidClickChevron(row: sender.cellRow, columnIndex: sender.cellColumnIndex) + } +} diff --git a/TablePro/Views/Results/Cells/DataGridDateCellView.swift b/TablePro/Views/Results/Cells/DataGridDateCellView.swift new file mode 100644 index 000000000..252b9c8db --- /dev/null +++ b/TablePro/Views/Results/Cells/DataGridDateCellView.swift @@ -0,0 +1,12 @@ +// +// DataGridDateCellView.swift +// TablePro +// + +import AppKit + +final class DataGridDateCellView: DataGridChevronCellView { + override class var reuseIdentifier: NSUserInterfaceItemIdentifier { + NSUserInterfaceItemIdentifier("dataCell.date") + } +} diff --git a/TablePro/Views/Results/Cells/DataGridDropdownCellView.swift b/TablePro/Views/Results/Cells/DataGridDropdownCellView.swift new file mode 100644 index 000000000..3af7116a3 --- /dev/null +++ b/TablePro/Views/Results/Cells/DataGridDropdownCellView.swift @@ -0,0 +1,12 @@ +// +// DataGridDropdownCellView.swift +// TablePro +// + +import AppKit + +final class DataGridDropdownCellView: DataGridChevronCellView { + override class var reuseIdentifier: NSUserInterfaceItemIdentifier { + NSUserInterfaceItemIdentifier("dataCell.dropdown") + } +} diff --git a/TablePro/Views/Results/Cells/DataGridForeignKeyCellView.swift b/TablePro/Views/Results/Cells/DataGridForeignKeyCellView.swift new file mode 100644 index 000000000..8f7d6f2a5 --- /dev/null +++ b/TablePro/Views/Results/Cells/DataGridForeignKeyCellView.swift @@ -0,0 +1,52 @@ +// +// DataGridForeignKeyCellView.swift +// TablePro +// + +import AppKit + +final class DataGridForeignKeyCellView: DataGridBaseCellView { + override class var reuseIdentifier: NSUserInterfaceItemIdentifier { + NSUserInterfaceItemIdentifier("dataCell.foreignKey") + } + + private lazy var fkButton: FKArrowButton = AccessoryButtonFactory.makeFKArrowButton() + + override func installAccessory() { + addSubview(fkButton) + NSLayoutConstraint.activate([ + fkButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4), + fkButton.centerYAnchor.constraint(equalTo: centerYAnchor), + fkButton.widthAnchor.constraint(equalToConstant: 16), + fkButton.heightAnchor.constraint(equalToConstant: 16), + ]) + fkButton.target = self + fkButton.action = #selector(handleFKClick(_:)) + } + + override func updateAccessoryVisibility(content: DataGridCellContent, state: DataGridCellState) { + let show = isAccessoryVisible(for: content) + fkButton.isHidden = !show + if show { + fkButton.fkRow = state.row + fkButton.fkColumnIndex = state.columnIndex + } else { + fkButton.fkRow = -1 + fkButton.fkColumnIndex = -1 + } + } + + override func textFieldTrailingInset(for content: DataGridCellContent, state: DataGridCellState) -> CGFloat { + isAccessoryVisible(for: content) ? -22 : -4 + } + + private func isAccessoryVisible(for content: DataGridCellContent) -> Bool { + guard let raw = content.rawValue else { return false } + return !raw.isEmpty + } + + @objc + private func handleFKClick(_ sender: FKArrowButton) { + accessoryDelegate?.dataGridCellDidClickFKArrow(row: sender.fkRow, columnIndex: sender.fkColumnIndex) + } +} diff --git a/TablePro/Views/Results/Cells/DataGridJsonCellView.swift b/TablePro/Views/Results/Cells/DataGridJsonCellView.swift new file mode 100644 index 000000000..4873a7f46 --- /dev/null +++ b/TablePro/Views/Results/Cells/DataGridJsonCellView.swift @@ -0,0 +1,12 @@ +// +// DataGridJsonCellView.swift +// TablePro +// + +import AppKit + +final class DataGridJsonCellView: DataGridChevronCellView { + override class var reuseIdentifier: NSUserInterfaceItemIdentifier { + NSUserInterfaceItemIdentifier("dataCell.json") + } +} diff --git a/TablePro/Views/Results/Cells/DataGridTextCellView.swift b/TablePro/Views/Results/Cells/DataGridTextCellView.swift new file mode 100644 index 000000000..a5850bf7a --- /dev/null +++ b/TablePro/Views/Results/Cells/DataGridTextCellView.swift @@ -0,0 +1,12 @@ +// +// DataGridTextCellView.swift +// TablePro +// + +import AppKit + +final class DataGridTextCellView: DataGridBaseCellView { + override class var reuseIdentifier: NSUserInterfaceItemIdentifier { + NSUserInterfaceItemIdentifier("dataCell.text") + } +} diff --git a/TablePro/Views/Results/DataGridCellFactory.swift b/TablePro/Views/Results/DataGridCellFactory.swift index f9c3f2cc5..6c7044c37 100644 --- a/TablePro/Views/Results/DataGridCellFactory.swift +++ b/TablePro/Views/Results/DataGridCellFactory.swift @@ -2,374 +2,28 @@ // DataGridCellFactory.swift // TablePro // -// Factory for creating and configuring data grid cells. -// Extracted from DataGridView coordinator for better maintainability. -// import AppKit -import QuartzCore - -@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 -} +import Foundation @MainActor final class DataGridCellFactory { - private let cellIdentifier = NSUserInterfaceItemIdentifier("DataCell") - private let rowNumberCellIdentifier = NSUserInterfaceItemIdentifier("RowNumberCell") - private let largeDatasetThreshold = 5_000 - - private var nullDisplayString: String = AppSettingsManager.shared.dataGrid.nullDisplay - private var settingsObserver: NSObjectProtocol? - - init() { - settingsObserver = NotificationCenter.default.addObserver( - forName: .dataGridSettingsDidChange, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.nullDisplayString = AppSettingsManager.shared.dataGrid.nullDisplay - } - } - } - - deinit { - if let observer = settingsObserver { - NotificationCenter.default.removeObserver(observer) - } - } - - // MARK: - Row Number Cell - - func makeRowNumberCell( - tableView: NSTableView, - row: Int, - cachedRowCount: Int, - visualState: RowVisualState - ) -> NSView { - let cellViewId = NSUserInterfaceItemIdentifier("RowNumberCellView") - let cellView: NSTableCellView - let cell: NSTextField - - if let reused = tableView.makeView(withIdentifier: cellViewId, owner: nil) as? NSTableCellView, - let textField = reused.textField { - cellView = reused - cell = textField - cell.font = ThemeEngine.shared.dataGridFonts.rowNumber - } else { - cellView = NSTableCellView() - cellView.identifier = cellViewId - - cell = NSTextField(labelWithString: "") - cell.alignment = .right - cell.font = ThemeEngine.shared.dataGridFonts.rowNumber - cell.tag = DataGridFontVariant.rowNumber - cell.textColor = .secondaryLabelColor - cell.translatesAutoresizingMaskIntoConstraints = false - - cellView.textField = cell - cellView.addSubview(cell) - - NSLayoutConstraint.activate([ - cell.leadingAnchor.constraint(equalTo: cellView.leadingAnchor, constant: 4), - cell.trailingAnchor.constraint(equalTo: cellView.trailingAnchor, constant: -4), - cell.centerYAnchor.constraint(equalTo: cellView.centerYAnchor), - ]) - } - - guard row >= 0 && row < cachedRowCount else { - cell.stringValue = "" - return cellView - } - - cell.stringValue = "\(row + 1)" - cell.textColor = visualState.isDeleted ? ThemeEngine.shared.colors.dataGrid.deletedText : .secondaryLabelColor - cellView.setAccessibilityLabel(String(format: String(localized: "Row %d"), row + 1)) - cellView.setAccessibilityRowIndexRange(NSRange(location: row, length: 1)) - - return cellView - } - - // MARK: - Data Cell - - func makeDataCell( - tableView: NSTableView, - row: Int, - columnIndex: Int, - displayValue: String?, - rawValue: String?, - visualState: RowVisualState, - isEditable: Bool, - isLargeDataset: Bool, - isFocused: Bool, - isDropdown: Bool = false, - isFKColumn: Bool = false, - fkArrowTarget: AnyObject? = nil, - fkArrowAction: Selector? = nil, - chevronTarget: AnyObject? = nil, - chevronAction: Selector? = nil, - delegate: NSTextFieldDelegate - ) -> NSView { - let gridCellView: DataGridCellView - let cell: NSTextField - - if let reused = tableView.makeView(withIdentifier: cellIdentifier, owner: nil) as? DataGridCellView, - let textField = reused.textField { - gridCellView = reused - cell = textField - } else { - gridCellView = DataGridCellView() - gridCellView.identifier = cellIdentifier - gridCellView.wantsLayer = true - - cell = CellTextField() - cell.font = ThemeEngine.shared.dataGridFonts.regular - cell.drawsBackground = false - cell.isBordered = false - cell.focusRingType = .none - cell.lineBreakMode = .byTruncatingTail - cell.maximumNumberOfLines = 1 - cell.cell?.truncatesLastVisibleLine = true - cell.cell?.usesSingleLineMode = true - cell.translatesAutoresizingMaskIntoConstraints = false - - gridCellView.textField = cell - gridCellView.addSubview(cell) - - let fkButton = createFKArrowButton() - gridCellView.addSubview(fkButton) - gridCellView.fkArrowButton = fkButton - - let chevron = createChevronButton() - gridCellView.addSubview(chevron) - gridCellView.chevronButton = chevron - - let trailing = cell.trailingAnchor.constraint(equalTo: gridCellView.trailingAnchor, constant: -4) - gridCellView.textFieldTrailing = trailing - - NSLayoutConstraint.activate([ - cell.leadingAnchor.constraint(equalTo: gridCellView.leadingAnchor, constant: 4), - trailing, - cell.centerYAnchor.constraint(equalTo: gridCellView.centerYAnchor), - - fkButton.trailingAnchor.constraint(equalTo: gridCellView.trailingAnchor, constant: -4), - fkButton.centerYAnchor.constraint(equalTo: gridCellView.centerYAnchor), - fkButton.widthAnchor.constraint(equalToConstant: 16), - fkButton.heightAnchor.constraint(equalToConstant: 16), - - chevron.trailingAnchor.constraint(equalTo: gridCellView.trailingAnchor, constant: -4), - chevron.centerYAnchor.constraint(equalTo: gridCellView.centerYAnchor), - chevron.widthAnchor.constraint(equalToConstant: 10), - chevron.heightAnchor.constraint(equalToConstant: 12), - ]) - } - - cell.lineBreakMode = .byTruncatingTail - cell.maximumNumberOfLines = 1 - cell.cell?.truncatesLastVisibleLine = true - cell.cell?.usesSingleLineMode = true - - let showFK = isFKColumn && rawValue != nil && rawValue?.isEmpty != true - let showChevron = isDropdown - - if let fkButton = gridCellView.fkArrowButton { - if showFK { - fkButton.isHidden = false - fkButton.target = fkArrowTarget - fkButton.action = fkArrowAction - fkButton.fkRow = row - fkButton.fkColumnIndex = columnIndex - } else { - fkButton.isHidden = true - fkButton.target = nil - fkButton.action = nil - fkButton.fkRow = -1 - fkButton.fkColumnIndex = -1 - } - } - - if let chevron = gridCellView.chevronButton { - if showChevron { - chevron.isHidden = false - chevron.target = chevronTarget - chevron.action = chevronAction - chevron.cellRow = row - chevron.cellColumnIndex = columnIndex - } else { - chevron.isHidden = true - chevron.target = nil - chevron.action = nil - chevron.cellRow = -1 - chevron.cellColumnIndex = -1 - } - } - - if showFK { - gridCellView.textFieldTrailing?.constant = -22 - } else if showChevron { - gridCellView.textFieldTrailing?.constant = -18 - } else { - gridCellView.textFieldTrailing?.constant = -4 - } - - cell.isEditable = isEditable - cell.delegate = delegate - cell.identifier = cellIdentifier - - let isDeleted = visualState.isDeleted - let isInserted = visualState.isInserted - let isModified = visualState.modifiedColumns.contains(columnIndex) - - configureTextContent(cell: cell, displayValue: displayValue, rawValue: rawValue, isLargeDataset: isLargeDataset) - - CATransaction.begin() - CATransaction.setDisableActions(true) - - if isDeleted { - gridCellView.changeBackgroundColor = ThemeEngine.shared.colors.dataGrid.deleted - } else if isInserted { - gridCellView.changeBackgroundColor = ThemeEngine.shared.colors.dataGrid.inserted - } else if isModified { - gridCellView.changeBackgroundColor = ThemeEngine.shared.colors.dataGrid.modified - } else { - gridCellView.changeBackgroundColor = nil - } - - gridCellView.isFocusedCell = isFocused - - CATransaction.commit() - - let accessibilityValue = rawValue ?? String(localized: "NULL") - cell.setAccessibilityLabel( - String(format: String(localized: "Row %d, column %d: %@"), row + 1, columnIndex + 1, accessibilityValue) - ) - gridCellView.setAccessibilityRowIndexRange(NSRange(location: row, length: 1)) - gridCellView.setAccessibilityColumnIndexRange(NSRange(location: columnIndex, length: 1)) - - return gridCellView - } - - // MARK: - Button Creation - - private func createFKArrowButton() -> 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 - } - - private func createChevronButton() -> 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 - } - - // MARK: - Cell Text Content - - private func configureTextContent( - cell: NSTextField, - displayValue: String?, - rawValue: String?, - isLargeDataset: Bool - ) { - cell.placeholderString = nil - - let cellTextField = cell as? CellTextField - - if rawValue == nil { - cell.stringValue = "" - cellTextField?.originalValue = nil - cell.font = ThemeEngine.shared.dataGridFonts.italic - cell.tag = DataGridFontVariant.italic - if !isLargeDataset { - cell.placeholderString = nullDisplayString - } - cell.textColor = .secondaryLabelColor - } else if rawValue == "__DEFAULT__" { - cell.stringValue = "" - cellTextField?.originalValue = nil - cell.font = ThemeEngine.shared.dataGridFonts.medium - cell.tag = DataGridFontVariant.medium - if !isLargeDataset { - cell.placeholderString = "DEFAULT" - } - cell.textColor = .systemBlue - } else if rawValue == "" { - cell.stringValue = "" - cellTextField?.originalValue = nil - cell.font = ThemeEngine.shared.dataGridFonts.italic - cell.tag = DataGridFontVariant.italic - if !isLargeDataset { - cell.placeholderString = "Empty" - } - cell.textColor = .secondaryLabelColor - } else { - cell.stringValue = displayValue ?? "" - cellTextField?.originalValue = rawValue - cell.textColor = .labelColor - cell.font = ThemeEngine.shared.dataGridFonts.regular - cell.tag = DataGridFontVariant.regular - } - } - - // MARK: - Column Width Calculation - - /// Minimum column width private static let minColumnWidth: CGFloat = 60 - /// Maximum column width - prevents overly wide columns private static let maxColumnWidth: CGFloat = 800 - /// Number of rows to sample for width calculation (for performance) private static let sampleRowCount = 30 - /// Maximum characters to consider per cell for width estimation private static let maxMeasureChars = 50 - /// Font for measuring header + private var headerFont: NSFont { NSFont.systemFont(ofSize: 13, weight: .semibold) } - /// Calculate column width based on header name only (used for initial display) func calculateColumnWidth(for columnName: String) -> CGFloat { let attributes: [NSAttributedString.Key: Any] = [.font: headerFont] let size = (columnName as NSString).size(withAttributes: attributes) - let width = size.width + 48 // padding for sort indicator + margins + let width = size.width + 48 return min(max(width, Self.minColumnWidth), Self.maxColumnWidth) } - /// Calculate optimal column width based on header and cell content. - /// - /// Since the cell font is monospaced, we avoid per-row CoreText measurement - /// and instead multiply character count by the pre-computed glyph advance width. - /// This reduces the cost from O(sampleRows * CoreText) to O(sampleRows * 1). func calculateOptimalColumnWidth( for columnName: String, columnIndex: Int, @@ -399,8 +53,6 @@ final class DataGridCellFactory { return min(max(maxWidth, Self.minColumnWidth), Self.maxColumnWidth) } - /// Calculate column width to fit content without max-width or max-chars caps. - /// Used for user-initiated "Size to Fit" (double-click divider, context menu). func calculateFitToContentWidth( for columnName: String, columnIndex: Int, @@ -427,8 +79,6 @@ final class DataGridCellFactory { } } -// MARK: - NSFont Extension - extension NSFont { func withTraits(_ traits: NSFontDescriptor.SymbolicTraits) -> NSFont { let descriptor = fontDescriptor.withSymbolicTraits(traits) @@ -436,11 +86,7 @@ extension NSFont { } } -// MARK: - String Extension for Cell Display - internal extension String { - /// Whether the string contains any Unicode line-break character - /// (LF, CR, VT, FF, NEL, LS, PS). Uses NSString UTF-16 loop for O(1) per-char access. var containsLineBreak: Bool { let nsString = self as NSString let length = nsString.length @@ -455,17 +101,12 @@ internal extension String { return false } - /// Sanitize string for single-line cell display by replacing line-break characters with spaces. - /// Covers: LF (0x0A), CR (0x0D), VT (0x0B), FF (0x0C), NEL (0x85), LS (0x2028), PS (0x2029). - /// Uses NSString UTF-16 loop for O(1) per-character access (project convention for large strings). var sanitizedForCellDisplay: String { let nsString = self as NSString let length = nsString.length guard length > 0 else { return self } - guard containsLineBreak else { return self } - // Slow path: build new string with line breaks replaced by spaces let mutable = NSMutableString(capacity: length) for i in 0.., + widthCalculator: (String, Int) -> CGFloat + ) { + attach(to: tableView) + let visibleCount = schema.columnNames.count + + growBackingPoolIfNeeded(to: visibleCount) + + let willRestoreWidths = !(savedLayout?.columnWidths.isEmpty ?? true) + let hiddenFromLayout = savedLayout?.hiddenColumns ?? [] + + for slot in 0..