diff --git a/CHANGELOG.md b/CHANGELOG.md index 637662915..5b5acfbec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Connection URL parsing: SSH `user:password@host` split, `safeModeLevel` from TablePlus URLs, case-insensitive query params - Connection URL export: SSH password, Redis database index, MongoDB auth params (`authSource`, `authMechanism`, `replicaSet`), and multi-host - SSH Private Key auth resolves keys from `~/.ssh/config` and default locations (`id_ed25519`, `id_rsa`, `id_ecdsa`) when no explicit key path is set +- Click a focused cell to start editing without a second click +- Data grid focus ring follows the system accent color and contrast settings +- Data grid cells expose accessibility row and column index ranges to VoiceOver on all dataset sizes ### Changed @@ -30,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - QueryTab.resultVersion split: schemaVersion (column shape) on QueryTab, row mutations through delegate deltas, sort completion through a single delegate replace call. Pin toggle, sort completion, and applyMultiStatementResults no longer fan out a redundant reload signal. - Row data lives in a per-coordinator RowDataStore keyed by tab.id rather than on QueryTab itself, so SwiftUI's @Observable tracking on tabManager.tabs no longer fires for row writes. - DataGridConfiguration is Equatable; DataGridIdentity covers tabType, tableName, and primaryKeyColumns so updateNSView short-circuits when nothing structural changed. DataTabGridDelegate properties are wired in onAppear / onChange instead of in the body. +- Date picker popover font follows the data grid font setting ### Fixed diff --git a/TablePro/Views/Results/CellOverlayEditor.swift b/TablePro/Views/Results/CellOverlayEditor.swift index 0fbe3e61c..6bae466f9 100644 --- a/TablePro/Views/Results/CellOverlayEditor.swift +++ b/TablePro/Views/Results/CellOverlayEditor.swift @@ -109,7 +109,7 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate { newPanel.contentView = scrollView newPanel.contentView?.wantsLayer = true newPanel.contentView?.layer?.borderWidth = 2 - newPanel.contentView?.layer?.borderColor = NSColor.selectedControlColor.safeCGColor + newPanel.contentView?.layer?.borderColor = NSColor.keyboardFocusIndicatorColor.safeCGColor newPanel.contentView?.layer?.cornerRadius = 2 newPanel.contentView?.layer?.masksToBounds = true diff --git a/TablePro/Views/Results/DataGridCellFactory.swift b/TablePro/Views/Results/DataGridCellFactory.swift index f34e6deb6..8427d60a1 100644 --- a/TablePro/Views/Results/DataGridCellFactory.swift +++ b/TablePro/Views/Results/DataGridCellFactory.swift @@ -23,40 +23,16 @@ final class CellChevronButton: NSButton { var cellColumnIndex: Int = -1 } -/// Factory for creating data grid cell views @MainActor final class DataGridCellFactory { private let cellIdentifier = NSUserInterfaceItemIdentifier("DataCell") private let rowNumberCellIdentifier = NSUserInterfaceItemIdentifier("RowNumberCell") - - /// Large dataset threshold - above this, disable expensive visual features private let largeDatasetThreshold = 5_000 - // MARK: - Cached Settings - - /// Cached NULL display string (updated via settings notification) private var nullDisplayString: String = AppSettingsManager.shared.dataGrid.nullDisplay private var settingsObserver: NSObjectProtocol? - // MARK: - Cached VoiceOver State - - private static var cachedVoiceOverEnabled: Bool = NSWorkspace.shared.isVoiceOverEnabled - // Observer lives for app lifetime — no removal needed since DataGridCellFactory is a static singleton cache - private static let voiceOverObserver: NSObjectProtocol? = { - NotificationCenter.default.addObserver( - forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification, - object: nil, - queue: .main - ) { _ in - Task { @MainActor in - DataGridCellFactory.cachedVoiceOverEnabled = NSWorkspace.shared.isVoiceOverEnabled - } - } - }() - init() { - _ = Self.voiceOverObserver - settingsObserver = NotificationCenter.default.addObserver( forName: .dataGridSettingsDidChange, object: nil, @@ -119,9 +95,8 @@ final class DataGridCellFactory { cell.stringValue = "\(row + 1)" cell.textColor = visualState.isDeleted ? ThemeEngine.shared.colors.dataGrid.deletedText : .secondaryLabelColor - if Self.cachedVoiceOverEnabled { - cellView.setAccessibilityLabel(String(format: String(localized: "Row %d"), row + 1)) - } + cellView.setAccessibilityLabel(String(format: String(localized: "Row %d"), row + 1)) + cellView.setAccessibilityRowIndexRange(NSRange(location: row, length: 1)) return cellView } @@ -259,23 +234,16 @@ final class DataGridCellFactory { gridCellView.changeBackgroundColor = nil } - if isLargeDataset { - gridCellView.layer?.borderWidth = 0 - } else if isFocused { - gridCellView.layer?.borderWidth = 2 - gridCellView.layer?.borderColor = ThemeEngine.shared.colors.dataGrid.focusBorderCG - } else { - gridCellView.layer?.borderWidth = 0 - } + gridCellView.isFocusedCell = isFocused CATransaction.commit() - if Self.cachedVoiceOverEnabled { - let accessibilityValue = rawValue ?? String(localized: "NULL") - cell.setAccessibilityLabel( - String(format: String(localized: "Row %d, column %d: %@"), row + 1, columnIndex + 1, accessibilityValue) - ) - } + 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 } diff --git a/TablePro/Views/Results/DataGridCellView.swift b/TablePro/Views/Results/DataGridCellView.swift index 8334c8235..4d4486cf6 100644 --- a/TablePro/Views/Results/DataGridCellView.swift +++ b/TablePro/Views/Results/DataGridCellView.swift @@ -5,15 +5,18 @@ import AppKit -/// Custom cell view that uses a background subview for change-state coloring. -/// AppKit's `NSTableRowView` sets `backgroundStyle` to `.emphasized` when the -/// row is selected — we hide the background view so the native selection highlight -/// shows through. final class DataGridCellView: NSTableCellView { var fkArrowButton: FKArrowButton? var chevronButton: CellChevronButton? var textFieldTrailing: NSLayoutConstraint? + var isFocusedCell: Bool = false { + didSet { + guard oldValue != isFocusedCell else { return } + updateFocusBorder() + } + } + private lazy var backgroundView: NSView = { let view = NSView() view.wantsLayer = true @@ -43,6 +46,18 @@ final class DataGridCellView: NSTableCellView { override var backgroundStyle: NSView.BackgroundStyle { didSet { backgroundView.isHidden = (backgroundStyle == .emphasized) || (changeBackgroundColor == nil) + if isFocusedCell { updateFocusBorder() } + } + } + + private func updateFocusBorder() { + if isFocusedCell { + layer?.borderWidth = 2 + layer?.borderColor = backgroundStyle == .emphasized + ? NSColor.white.withAlphaComponent(0.8).cgColor + : NSColor.keyboardFocusIndicatorColor.cgColor + } else { + layer?.borderWidth = 0 } } } diff --git a/TablePro/Views/Results/DatePickerCellEditor.swift b/TablePro/Views/Results/DatePickerCellEditor.swift index 6e6d0a62e..b8e2177a9 100644 --- a/TablePro/Views/Results/DatePickerCellEditor.swift +++ b/TablePro/Views/Results/DatePickerCellEditor.swift @@ -71,7 +71,8 @@ final class DatePickerCellEditor: NSDatePicker { private func setupUI() { datePickerStyle = .textFieldAndStepper datePickerElements = [.yearMonthDay, .hourMinuteSecond] - font = .monospacedSystemFont(ofSize: 13, weight: .regular) + let pointSize = ThemeEngine.shared.dataGridFonts.regular.pointSize + font = .monospacedSystemFont(ofSize: pointSize, weight: .regular) isBezeled = false isBordered = false drawsBackground = false diff --git a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift index 0e21d41f4..e2631a84f 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift @@ -7,57 +7,67 @@ import AppKit import SwiftUI extension TableViewCoordinator { - func tableView(_ tableView: NSTableView, shouldEdit tableColumn: NSTableColumn?, row: Int) -> Bool { - guard isEditable, - let tableColumn = tableColumn else { return false } + enum InlineEditEligibility { + case eligible + case needsOverlayEditor(value: String) + case blocked + } - let columnId = tableColumn.identifier.rawValue - guard columnId != "__rowNumber__", - !changeManager.isRowDeleted(row) else { return false } + func inlineEditEligibility(row: Int, columnIndex: Int) -> InlineEditEligibility { + guard isEditable else { return .blocked } + guard row >= 0, columnIndex >= 0, columnIndex < rowProvider.columns.count else { return .blocked } + guard !changeManager.isRowDeleted(row) else { return .blocked } let immutable = databaseType.map { PluginManager.shared.immutableColumns(for: $0) } ?? [] - if !immutable.isEmpty, - let columnIndex = DataGridView.dataColumnIndex(from: tableColumn.identifier), - columnIndex < rowProvider.columns.count, - immutable.contains(rowProvider.columns[columnIndex]) { - return false + if immutable.contains(rowProvider.columns[columnIndex]) { + return .blocked } - // Popover-editor columns (date/FK/JSON) are only editable via - // double-click (handleDoubleClick). Block inline editing for them. - if let columnIndex = DataGridView.dataColumnIndex(from: tableColumn.identifier) { - if columnIndex < rowProvider.columns.count { - let columnName = rowProvider.columns[columnIndex] - if rowProvider.columnForeignKeys[columnName] != nil { return false } - } - if columnIndex < rowProvider.columnTypes.count { - let ct = rowProvider.columnTypes[columnIndex] - if ct.isDateType || ct.isJsonType || ct.isEnumType || ct.isSetType || ct.isBlobType || ct.isBooleanType { return false } - } - if let dropdownCols = dropdownColumns, dropdownCols.contains(columnIndex) { - return false - } - if let typePickerCols = typePickerColumns, typePickerCols.contains(columnIndex) { - return false - } + let columnName = rowProvider.columns[columnIndex] + if rowProvider.columnForeignKeys[columnName] != nil { return .blocked } - // Text columns containing JSON use JSON editor popover - if let value = rowProvider.value(atRow: row, column: columnIndex), - value.looksLikeJson { - return false + if columnIndex < rowProvider.columnTypes.count { + let ct = rowProvider.columnTypes[columnIndex] + if ct.isBooleanType || ct.isDateType || ct.isJsonType + || ct.isBlobType || ct.isEnumType || ct.isSetType { + return .blocked } + } - // Multiline values use overlay editor — block inline field editor - if let value = rowProvider.value(atRow: row, column: columnIndex), - value.containsLineBreak { - let tableColumnIdx = tableView.column(withIdentifier: tableColumn.identifier) - guard tableColumnIdx >= 0 else { return false } - showOverlayEditor(tableView: tableView, row: row, column: tableColumnIdx, columnIndex: columnIndex, value: value) - return false - } + if dropdownColumns?.contains(columnIndex) == true { return .blocked } + if typePickerColumns?.contains(columnIndex) == true { return .blocked } + + if let value = rowProvider.value(atRow: row, column: columnIndex) { + if value.containsLineBreak { return .needsOverlayEditor(value: value) } + if value.looksLikeJson { return .blocked } } - return true + return .eligible + } + + func canStartInlineEdit(row: Int, columnIndex: Int) -> Bool { + if case .eligible = inlineEditEligibility(row: row, columnIndex: columnIndex) { + return true + } + return false + } + + func tableView(_ tableView: NSTableView, shouldEdit tableColumn: NSTableColumn?, row: Int) -> Bool { + guard let tableColumn else { return false } + guard tableColumn.identifier.rawValue != "__rowNumber__" else { return false } + guard let columnIndex = DataGridView.dataColumnIndex(from: tableColumn.identifier) else { return false } + + switch inlineEditEligibility(row: row, columnIndex: columnIndex) { + case .eligible: + return true + case .needsOverlayEditor(let value): + let tableColumnIdx = tableView.column(withIdentifier: tableColumn.identifier) + guard tableColumnIdx >= 0 else { return false } + showOverlayEditor(tableView: tableView, row: row, column: tableColumnIdx, columnIndex: columnIndex, value: value) + return false + case .blocked: + return false + } } // MARK: - Overlay Editor (Multiline) diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 73f66b600..78d2e7703 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -59,7 +59,6 @@ final class KeyHandlingTableView: NSTableView { // MARK: - TablePlus-Style Cell Focus override func mouseDown(with event: NSEvent) { - // Become first responder to capture keyboard events (especially Delete key) window?.makeFirstResponder(self) let point = convert(event.locationInWindow, from: nil) @@ -71,22 +70,24 @@ final class KeyHandlingTableView: NSTableView { return } - // Reset anchor/pivot when clicking without Shift if clickedRow >= 0 && !event.modifierFlags.contains(.shift) { selectionAnchor = clickedRow selectionPivot = clickedRow } + let alreadyFocusedHere = clickedRow >= 0 + && clickedColumn >= 0 + && clickedRow == focusedRow + && clickedColumn == focusedColumn + super.mouseDown(with: event) - // Only handle editing for valid clicks on data cells (not row number column) guard clickedRow >= 0, clickedColumn >= 0, clickedColumn < numberOfColumns else { return } - // Skip row number column let column = tableColumns[clickedColumn] if column.identifier.rawValue == "__rowNumber__" { focusedRow = -1 @@ -94,9 +95,15 @@ final class KeyHandlingTableView: NSTableView { return } - // Update focus (edit mode is triggered by double-click, not single click) focusedRow = clickedRow focusedColumn = clickedColumn + + if alreadyFocusedHere && event.clickCount == 1 && selectedRowIndexes.count == 1 { + let dataColumnIndex = DataGridView.dataColumnIndex(for: clickedColumn) + if coordinator?.canStartInlineEdit(row: clickedRow, columnIndex: dataColumnIndex) == true { + editColumn(clickedColumn, row: clickedRow, with: nil, select: true) + } + } } // MARK: - Standard Edit Menu Actions diff --git a/TablePro/Views/Results/TableRowViewWithMenu.swift b/TablePro/Views/Results/TableRowViewWithMenu.swift index c703a8c9c..645d25e2d 100644 --- a/TablePro/Views/Results/TableRowViewWithMenu.swift +++ b/TablePro/Views/Results/TableRowViewWithMenu.swift @@ -130,7 +130,6 @@ final class TableRowViewWithMenu: NSTableRowView { menu.addItem(NSMenuItem.separator()) } - // Set Value (editable + column clicked) if coordinator.isEditable && dataColumnIndex >= 0 { let setValueMenu = NSMenu() @@ -140,17 +139,27 @@ final class TableRowViewWithMenu: NSTableRowView { emptyItem.target = self setValueMenu.addItem(emptyItem) - let nullItem = NSMenuItem( - title: String(localized: "NULL"), action: #selector(setNullValue(_:)), keyEquivalent: "") - nullItem.representedObject = dataColumnIndex - nullItem.target = self - setValueMenu.addItem(nullItem) - - let defaultItem = NSMenuItem( - title: String(localized: "Default"), action: #selector(setDefaultValue(_:)), keyEquivalent: "") - defaultItem.representedObject = dataColumnIndex - defaultItem.target = self - setValueMenu.addItem(defaultItem) + let columnName = dataColumnIndex < coordinator.rowProvider.columns.count + ? coordinator.rowProvider.columns[dataColumnIndex] + : nil + + let isNullable = columnName.flatMap { coordinator.rowProvider.columnNullable[$0] } ?? true + if isNullable { + let nullItem = NSMenuItem( + title: String(localized: "NULL"), action: #selector(setNullValue(_:)), keyEquivalent: "") + nullItem.representedObject = dataColumnIndex + nullItem.target = self + setValueMenu.addItem(nullItem) + } + + let hasDefault = columnName.flatMap({ coordinator.rowProvider.columnDefaults[$0] ?? nil }) != nil + if hasDefault { + let defaultItem = NSMenuItem( + title: String(localized: "Default"), action: #selector(setDefaultValue(_:)), keyEquivalent: "") + defaultItem.representedObject = dataColumnIndex + defaultItem.target = self + setValueMenu.addItem(defaultItem) + } let setValueItem = NSMenuItem(title: String(localized: "Set Value"), action: nil, keyEquivalent: "") setValueItem.submenu = setValueMenu