diff --git a/CHANGELOG.md b/CHANGELOG.md index c17f723cc..2d91e37dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Translucent backgrounds (Welcome sidebar, settings banners, ER diagram toolbar, JSON editor controls, Pro feature scrim) honor the system Reduce Transparency and Increase Contrast accessibility settings, swapping the material for a solid surface color when either is on - Internal: result-grid sortable header drops the custom resize cursor handling that duplicated AppKit's built-in column-edge resize, and consolidates three sort delegate methods into one that carries the full sort state. No user-facing change; multi-column sort, shift-click cycle, and the column resize cursor still work the same. - Internal: Redis sidebar key tree uses SwiftUI `OutlineGroup` instead of recursive `DisclosureGroup` + `ForEach` wrapped in `AnyView`. Expansion state is now managed natively per branch identifier; the explicit `expandedPrefixes` set is gone. +- Result-grid cells render via direct `draw(_:)` on a layer-backed `NSView` instead of an `NSTableCellView` wrapping an `NSTextField` plus an `NSButton` accessory. Per cell during scroll there is no Auto Layout solving, no `NSTextField` re-layout, and no `NSButton` tracking-area work. Editing for plain-text columns now opens the overlay editor (the same surface previously used for multi-line cells) rather than an inline text field. ### Fixed diff --git a/TablePro/Core/Services/Infrastructure/TabWindowController.swift b/TablePro/Core/Services/Infrastructure/TabWindowController.swift index f1338a78b..61f2e92ed 100644 --- a/TablePro/Core/Services/Infrastructure/TabWindowController.swift +++ b/TablePro/Core/Services/Infrastructure/TabWindowController.swift @@ -34,12 +34,6 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { internal static let frameAutosaveName: NSWindow.FrameAutosaveName = "MainEditorWindow" - private lazy var dataGridFieldEditor: DataGridFieldEditor = { - let editor = DataGridFieldEditor() - editor.isFieldEditor = true - return editor - }() - internal let payload: EditorTabPayload internal let controllerId: UUID @@ -101,11 +95,6 @@ internal final class TabWindowController: NSWindowController, NSWindowDelegate { // MARK: - NSWindowDelegate - func windowWillReturnFieldEditor(_ sender: NSWindow, to client: Any?) -> Any? { - guard client is CellTextField else { return nil } - return dataGridFieldEditor - } - internal func windowDidResize(_ notification: Notification) { guard let window = notification.object as? NSWindow else { return } guard !window.inLiveResize else { return } diff --git a/TablePro/Views/Results/CellOverlayEditor.swift b/TablePro/Views/Results/CellOverlayEditor.swift index 119aa4e8b..a07d4a687 100644 --- a/TablePro/Views/Results/CellOverlayEditor.swift +++ b/TablePro/Views/Results/CellOverlayEditor.swift @@ -2,29 +2,28 @@ // CellOverlayEditor.swift // TablePro // -// Overlay editor for multiline cell values. -// Uses a borderless NSPanel containing an NSScrollView + NSTextView, -// bypassing NSTextFieldCell's field editor which cannot scroll vertically. -// import AppKit @MainActor final class CellOverlayEditor: NSObject, NSTextViewDelegate { - private var panel: CellOverlayPanel? + private var container: OverlayContainerView? + private var textView: OverlayTextView? private weak var tableView: NSTableView? private var scrollObserver: NSObjectProtocol? private var columnResizeObserver: NSObjectProtocol? + private var appResignObserver: NSObjectProtocol? + private var windowResignKeyObserver: NSObjectProtocol? + private var outsideClickMonitor: Any? private(set) var row: Int = -1 private(set) var column: Int = -1 private(set) var columnIndex: Int = -1 var onCommit: ((_ row: Int, _ columnIndex: Int, _ newValue: String) -> Void)? - var onTabNavigation: ((_ row: Int, _ column: Int, _ forward: Bool) -> Void)? - var isActive: Bool { panel != nil } + var isActive: Bool { container != nil } // MARK: - Show / Dismiss @@ -42,85 +41,97 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate { self.column = column self.columnIndex = columnIndex - guard let cellView = tableView.view(atColumn: column, row: row, makeIfNecessary: false) else { return } + let cellFrame = tableView.frameOfCell(atColumn: column, row: row) + guard !cellFrame.isEmpty else { return } guard let window = tableView.window else { return } - let cellRectInWindow = cellView.convert(cellView.bounds, to: nil) - let cellRectOnScreen = window.convertToScreen(cellRectInWindow) - - let lineHeight: CGFloat = ThemeEngine.shared.dataGridFonts.regular.boundingRectForFont.height + 4 + let lineHeight = ThemeEngine.shared.dataGridFonts.regular.boundingRectForFont.height + 4 var newlineCount = 0 for scalar in value.unicodeScalars where scalar == "\n" { newlineCount += 1 } let lineCount = CGFloat(newlineCount + 1) - let contentHeight = max(lineCount * lineHeight + 8, cellRectOnScreen.height) - let overlayHeight = min(contentHeight, 120) + let contentHeight = max(lineCount * lineHeight + 8, cellFrame.height) + let overlayHeight = min(max(contentHeight, cellFrame.height), 120) - let panelRect = NSRect( - x: cellRectOnScreen.origin.x, - y: cellRectOnScreen.origin.y - (overlayHeight - cellRectOnScreen.height), - width: cellRectOnScreen.width, + let editorFrame = NSRect( + x: cellFrame.origin.x, + y: cellFrame.origin.y, + width: cellFrame.width, height: overlayHeight ) - let contentSize = NSSize(width: panelRect.width, height: panelRect.height) - - let textView = OverlayTextView(frame: NSRect(origin: .zero, size: contentSize)) - textView.overlayEditor = self - textView.isRichText = false - textView.allowsUndo = true - textView.font = ThemeEngine.shared.dataGridFonts.regular - textView.textColor = .labelColor - textView.backgroundColor = .textBackgroundColor - textView.isVerticallyResizable = true - textView.isHorizontallyResizable = false - textView.textContainer?.widthTracksTextView = true - textView.textContainer?.containerSize = NSSize( - width: contentSize.width, - height: CGFloat.greatestFiniteMagnitude - ) - textView.delegate = self - textView.string = value - textView.selectAll(nil) + let containerView = OverlayContainerView(frame: editorFrame) + containerView.wantsLayer = true + containerView.layer?.borderWidth = 2 + containerView.layer?.borderColor = NSColor.keyboardFocusIndicatorColor.cgColor + containerView.layer?.cornerRadius = 2 + containerView.layer?.masksToBounds = true + containerView.layer?.backgroundColor = NSColor.textBackgroundColor.cgColor - let scrollView = NSScrollView(frame: NSRect(origin: .zero, size: contentSize)) + let scrollView = NSScrollView(frame: containerView.bounds) + scrollView.autoresizingMask = [.width, .height] scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = false scrollView.autohidesScrollers = true scrollView.borderType = .noBorder - scrollView.documentView = textView scrollView.drawsBackground = true scrollView.backgroundColor = .textBackgroundColor - scrollView.autoresizingMask = [.width, .height] - let newPanel = CellOverlayPanel( - contentRect: panelRect, - styleMask: [.borderless, .nonactivatingPanel], - backing: .buffered, - defer: false + let editorTextView = OverlayTextView(frame: scrollView.bounds) + editorTextView.overlayEditor = self + editorTextView.isRichText = false + editorTextView.allowsUndo = true + editorTextView.font = ThemeEngine.shared.dataGridFonts.regular + editorTextView.textColor = .labelColor + editorTextView.backgroundColor = .textBackgroundColor + editorTextView.isVerticallyResizable = true + editorTextView.isHorizontallyResizable = false + editorTextView.textContainer?.widthTracksTextView = true + editorTextView.textContainer?.containerSize = NSSize( + width: scrollView.bounds.width, + height: CGFloat.greatestFiniteMagnitude ) - newPanel.level = .floating - newPanel.hidesOnDeactivate = false - newPanel.isReleasedWhenClosed = false - newPanel.hasShadow = true - newPanel.backgroundColor = .textBackgroundColor - newPanel.isOpaque = false - newPanel.contentView = scrollView - newPanel.contentView?.wantsLayer = true - newPanel.contentView?.layer?.borderWidth = 2 - newPanel.contentView?.layer?.borderColor = NSColor.keyboardFocusIndicatorColor.safeCGColor - newPanel.contentView?.layer?.cornerRadius = 2 - newPanel.contentView?.layer?.masksToBounds = true + editorTextView.delegate = self + editorTextView.string = value + editorTextView.selectAll(nil) + + scrollView.documentView = editorTextView + containerView.addSubview(scrollView) + + tableView.addSubview(containerView) + container = containerView + textView = editorTextView + + window.makeFirstResponder(editorTextView) + + installDismissObservers() + } + + func dismiss(commit: Bool) { + guard let activeContainer = container, let activeTextView = textView else { return } - newPanel.onResignKey = { [weak self] in - self?.dismiss(commit: true) + let newValue = activeTextView.string + + removeDismissObservers() + + activeContainer.removeFromSuperview() + container = nil + textView = nil + + if let tableView { + tableView.window?.makeFirstResponder(tableView) + } + + if commit { + onCommit?(row, columnIndex, newValue) } + } - panel = newPanel + // MARK: - Observers - newPanel.makeKeyAndOrderFront(nil) - newPanel.makeFirstResponder(textView) + private func installDismissObservers() { + guard let tableView else { return } if let clipView = tableView.enclosingScrollView?.contentView { scrollObserver = NotificationCenter.default.addObserver( @@ -143,17 +154,39 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate { self?.dismiss(commit: false) } } - } - func dismiss(commit: Bool) { - guard let activePanel = panel, - let scrollView = activePanel.contentView as? NSScrollView, - let textView = scrollView.documentView as? NSTextView else { return } + appResignObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.dismiss(commit: true) + } + } - let newValue = textView.string + if let editorWindow = tableView.window { + windowResignKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: editorWindow, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.dismiss(commit: true) + } + } + } - activePanel.onResignKey = nil + outsideClickMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in + guard let self else { return event } + Task { @MainActor [weak self] in + self?.handleOutsideClick(event: event) + } + return event + } + } + private func removeDismissObservers() { if let observer = scrollObserver { NotificationCenter.default.removeObserver(observer) scrollObserver = nil @@ -162,16 +195,27 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate { NotificationCenter.default.removeObserver(observer) columnResizeObserver = nil } - - activePanel.orderOut(nil) - panel = nil - - if let tableView { - tableView.window?.makeFirstResponder(tableView) + if let observer = appResignObserver { + NotificationCenter.default.removeObserver(observer) + appResignObserver = nil + } + if let observer = windowResignKeyObserver { + NotificationCenter.default.removeObserver(observer) + windowResignKeyObserver = nil + } + if let monitor = outsideClickMonitor { + NSEvent.removeMonitor(monitor) + outsideClickMonitor = nil } + } - if commit { - onCommit?(row, columnIndex, newValue) + private func handleOutsideClick(event: NSEvent) { + guard let containerView = container, + let containerWindow = containerView.window, + event.window === containerWindow else { return } + let frameInWindow = containerView.convert(containerView.bounds, to: nil) + if !frameInWindow.contains(event.locationInWindow) { + dismiss(commit: true) } } @@ -210,17 +254,10 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate { } } -// MARK: - Overlay Panel +// MARK: - Container View -private final class CellOverlayPanel: NSPanel { - var onResignKey: (() -> Void)? - - override var canBecomeKey: Bool { true } - - override func resignKey() { - super.resignKey() - onResignKey?() - } +private final class OverlayContainerView: NSView { + override var isFlipped: Bool { true } } // MARK: - Overlay Text View diff --git a/TablePro/Views/Results/CellTextField.swift b/TablePro/Views/Results/CellTextField.swift deleted file mode 100644 index 4e3d55915..000000000 --- a/TablePro/Views/Results/CellTextField.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// CellTextField.swift -// TablePro -// -// Custom text field that delegates context menu to row view. -// Extracted from DataGridView for better maintainability. -// - -import AppKit - -/// NSTextField subclass that shows row context menu instead of text editing menu -final class CellTextField: NSTextField { - /// The original (non-truncated) value for editing - var originalValue: String? - - /// The truncated display value - private var truncatedValue: String? - - override var stringValue: String { - didSet { - // Store the truncated value when set externally - truncatedValue = stringValue - } - } - - override func becomeFirstResponder() -> Bool { - if let original = originalValue { - super.stringValue = original - } - return super.becomeFirstResponder() - } - - /// Call this when editing ends to restore truncated display - func restoreTruncatedDisplay() { - if let truncated = truncatedValue { - super.stringValue = truncated - } - } - - /// Override right mouse down to end editing and show row context menu - override func rightMouseDown(with event: NSEvent) { - window?.makeFirstResponder(nil) - - var view: NSView? = self - while let parent = view?.superview { - if let rowView = parent as? DataGridRowView { - if let menu = rowView.menu(for: event) { - NSMenu.popUpContextMenu(menu, with: event, for: self) - } - return - } - view = parent - } - } - - override func menu(for event: NSEvent) -> NSMenu? { - window?.makeFirstResponder(nil) - - var view: NSView? = self - while let parent = view?.superview { - if let rowView = parent as? DataGridRowView { - return rowView.menu(for: event) - } - view = parent - } - - return nil - } -} - -final class DataGridFieldEditor: NSTextView { - private static let menuKeyEquivalents: Set = ["s"] - - override func performKeyEquivalent(with event: NSEvent) -> Bool { - if event.modifierFlags.contains(.command), - let chars = event.charactersIgnoringModifiers, - Self.menuKeyEquivalents.contains(chars) { - window?.makeFirstResponder(nil) - return false - } - return super.performKeyEquivalent(with: event) - } -} diff --git a/TablePro/Views/Results/Cells/DataGridCellRegistry.swift b/TablePro/Views/Results/Cells/DataGridCellRegistry.swift index 3ee1dab9e..a6f940a02 100644 --- a/TablePro/Views/Results/Cells/DataGridCellRegistry.swift +++ b/TablePro/Views/Results/Cells/DataGridCellRegistry.swift @@ -10,7 +10,6 @@ import Foundation @MainActor final class DataGridCellRegistry { weak var accessoryDelegate: DataGridCellAccessoryDelegate? - weak var textFieldDelegate: NSTextFieldDelegate? private(set) var nullDisplayString: String private(set) var palette: DataGridCellPalette @@ -63,7 +62,6 @@ final class DataGridCellRegistry { let cell = DataGridCellView(frame: .zero) cell.identifier = DataGridCellView.reuseIdentifier cell.accessoryDelegate = accessoryDelegate - cell.cellTextField.delegate = textFieldDelegate cell.nullDisplayString = nullDisplayString return cell } diff --git a/TablePro/Views/Results/Cells/DataGridCellView.swift b/TablePro/Views/Results/Cells/DataGridCellView.swift index a332d3353..29d64cd5e 100644 --- a/TablePro/Views/Results/Cells/DataGridCellView.swift +++ b/TablePro/Views/Results/Cells/DataGridCellView.swift @@ -6,121 +6,72 @@ import AppKit @MainActor -final class DataGridCellView: NSTableCellView { +final class DataGridCellView: NSView { static let reuseIdentifier = NSUserInterfaceItemIdentifier("dataCell") - let cellTextField: CellTextField weak var accessoryDelegate: DataGridCellAccessoryDelegate? var nullDisplayString: String = "" - var kind: DataGridCellKind = .text + private(set) var kind: DataGridCellKind = .text private(set) var cellRow: Int = -1 private(set) var cellColumnIndex: Int = -1 + private var displayText: String = "" + private var rawValue: String? + private var placeholder: DataGridCellPlaceholder? + private var isLargeDataset: Bool = false + private var isEditableCell: Bool = false + + private var textFont: NSFont = NSFont.systemFont(ofSize: NSFont.systemFontSize) + private var textColor: NSColor = .labelColor private var modifiedColumnTint: NSColor? - private var deletedRowTextColor: NSColor? - private var accessoryVisible: Bool = false + + private var visualState: RowVisualState = .empty private var isFocusedCell: Bool = false private var onEmphasizedSelection: Bool = false - private var textFieldTrailingConstraint: NSLayoutConstraint! - private var accessoryWidthConstraint: NSLayoutConstraint! - private var accessoryHeightConstraint: NSLayoutConstraint! - - private static let fkSymbol = makeSymbol( - name: "arrow.right.circle.fill", - accessibilityDescription: String(localized: "Navigate to referenced row") - ) - private static let chevronSymbol = makeSymbol( - name: "chevron.up.chevron.down", - accessibilityDescription: String(localized: "Open editor") - ) - - private lazy var accessoryButton: NSButton = { - let button = NSButton() - button.bezelStyle = .inline - button.isBordered = false - button.imageScaling = .scaleProportionallyDown - button.translatesAutoresizingMaskIntoConstraints = false - button.target = self - button.action = #selector(handleAccessoryClick(_:)) - button.isHidden = true - button.setContentHuggingPriority(.required, for: .horizontal) - button.setContentCompressionResistancePriority(.required, for: .horizontal) - addSubview(button) - - accessoryWidthConstraint = button.widthAnchor.constraint(equalToConstant: 0) - accessoryHeightConstraint = button.heightAnchor.constraint(equalToConstant: 0) - NSLayoutConstraint.activate([ - button.trailingAnchor.constraint( - equalTo: trailingAnchor, - constant: -DataGridMetrics.cellHorizontalInset - ), - button.centerYAnchor.constraint(equalTo: centerYAnchor), - accessoryWidthConstraint, - accessoryHeightConstraint, - ]) - return button + private var attributedCache: NSAttributedString? + + private var accessoryHitRect: NSRect = .zero + + private static let chevronNormal = makeAccessoryImage("chevron.up.chevron.down", pointSize: 10, color: .secondaryLabelColor) + private static let chevronEmphasized = makeAccessoryImage("chevron.up.chevron.down", pointSize: 10, color: .alternateSelectedControlTextColor) + private static let fkArrowNormal = makeAccessoryImage("arrow.right.circle.fill", pointSize: 14, color: .secondaryLabelColor) + private static let fkArrowEmphasized = makeAccessoryImage("arrow.right.circle.fill", pointSize: 14, color: .alternateSelectedControlTextColor) + + private static func makeAccessoryImage(_ name: String, pointSize: CGFloat, color: NSColor) -> NSImage { + let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .regular) + .applying(.init(hierarchicalColor: color)) + return NSImage(systemSymbolName: name, accessibilityDescription: nil)? + .withSymbolConfiguration(config) ?? NSImage() + } + + private static let placeholderParagraph: NSParagraphStyle = { + let p = NSMutableParagraphStyle() + p.lineBreakMode = .byTruncatingTail + return p }() 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 static func makeSymbol(name: String, accessibilityDescription: String) -> NSImage { - guard let image = NSImage(systemSymbolName: name, accessibilityDescription: accessibilityDescription) else { - return NSImage() - } - image.isTemplate = true - return image - } - private func commonInit() { wantsLayer = true layerContentsRedrawPolicy = .onSetNeedsDisplay canDrawSubviewsIntoLayer = true - - addSubview(cellTextField) - textFieldTrailingConstraint = cellTextField.trailingAnchor.constraint( - equalTo: trailingAnchor, - constant: -DataGridMetrics.cellHorizontalInset - ) - NSLayoutConstraint.activate([ - cellTextField.leadingAnchor.constraint( - equalTo: leadingAnchor, - constant: DataGridMetrics.cellHorizontalInset - ), - textFieldTrailingConstraint, - cellTextField.centerYAnchor.constraint(equalTo: centerYAnchor), - ]) - setAccessibilityElement(true) setAccessibilityRole(.cell) } override var allowsVibrancy: Bool { false } + override var isFlipped: Bool { true } override func makeBackingLayer() -> CALayer { let layer = super.makeBackingLayer() @@ -146,76 +97,44 @@ final class DataGridCellView: NSTableCellView { cellRow = state.row cellColumnIndex = state.columnIndex - applyContent(content, isLargeDataset: state.isLargeDataset, visualState: state.visualState, palette: palette) - applyVisualState(state, palette: palette) - - cellTextField.isEditable = state.isEditable && !state.visualState.isDeleted - - let newAccessoryVisible = computeAccessoryVisibility(content: content, state: state) - let newInset = trailingInset(for: newAccessoryVisible) - if textFieldTrailingConstraint.constant != newInset { - textFieldTrailingConstraint.constant = newInset - } - if newAccessoryVisible != accessoryVisible { - accessoryVisible = newAccessoryVisible - } - configureAccessoryButton() - - cellTextField.setAccessibilityLabel(content.accessibilityLabel) - setAccessibilityRowIndexRange(NSRange(location: state.row, length: 1)) - setAccessibilityColumnIndexRange(NSRange(location: state.columnIndex, length: 1)) - } - - private func applyContent( - _ content: DataGridCellContent, - isLargeDataset: Bool, - visualState: RowVisualState, - palette: DataGridCellPalette - ) { - cellTextField.placeholderString = nil - deletedRowTextColor = visualState.isDeleted ? palette.deletedRowText : nil + let nextDisplayText: String + let nextFont: NSFont + let nextColor: NSColor + let deletedTextColor = state.visualState.isDeleted ? palette.deletedRowText : nil switch content.placeholder { case .none: - cellTextField.stringValue = content.displayText - cellTextField.originalValue = content.rawValue - cellTextField.font = palette.regularFont - cellTextField.tag = DataGridFontVariant.regular - cellTextField.textColor = deletedRowTextColor ?? .labelColor - + nextDisplayText = content.displayText + nextFont = palette.regularFont + nextColor = deletedTextColor ?? .labelColor case .null: - cellTextField.stringValue = "" - cellTextField.originalValue = nil - cellTextField.font = palette.italicFont - cellTextField.tag = DataGridFontVariant.italic - cellTextField.textColor = deletedRowTextColor ?? .secondaryLabelColor - if !isLargeDataset { - cellTextField.placeholderString = nullDisplayString - } - + nextDisplayText = state.isLargeDataset ? "" : nullDisplayString + nextFont = palette.italicFont + nextColor = deletedTextColor ?? .secondaryLabelColor case .empty: - cellTextField.stringValue = "" - cellTextField.originalValue = nil - cellTextField.font = palette.italicFont - cellTextField.tag = DataGridFontVariant.italic - cellTextField.textColor = deletedRowTextColor ?? .secondaryLabelColor - if !isLargeDataset { - cellTextField.placeholderString = String(localized: "Empty") - } - + nextDisplayText = state.isLargeDataset ? "" : String(localized: "Empty") + nextFont = palette.italicFont + nextColor = deletedTextColor ?? .secondaryLabelColor case .defaultMarker: - cellTextField.stringValue = "" - cellTextField.originalValue = nil - cellTextField.font = palette.mediumFont - cellTextField.tag = DataGridFontVariant.medium - cellTextField.textColor = deletedRowTextColor ?? .systemBlue - if !isLargeDataset { - cellTextField.placeholderString = String(localized: "DEFAULT") - } + nextDisplayText = state.isLargeDataset ? "" : String(localized: "DEFAULT") + nextFont = palette.mediumFont + nextColor = deletedTextColor ?? .systemBlue } - } - private func applyVisualState(_ state: DataGridCellState, palette: DataGridCellPalette) { + if displayText != nextDisplayText + || textFont != nextFont + || textColor != nextColor { + displayText = nextDisplayText + textFont = nextFont + textColor = nextColor + attributedCache = nil + } + + rawValue = content.rawValue + placeholder = content.placeholder + isLargeDataset = state.isLargeDataset + isEditableCell = state.isEditable + let nextTint: NSColor? if state.visualState.isDeleted || state.visualState.isInserted { nextTint = nil @@ -224,27 +143,41 @@ final class DataGridCellView: NSTableCellView { } else { nextTint = nil } - if !colorsEqual(modifiedColumnTint, nextTint) { modifiedColumnTint = nextTint - needsDisplay = true } + visualState = state.visualState if isFocusedCell != state.isFocused { isFocusedCell = state.isFocused updateFocusPresentation() } + + setAccessibilityLabel(content.accessibilityLabel) + setAccessibilityRowIndexRange(NSRange(location: state.row, length: 1)) + setAccessibilityColumnIndexRange(NSRange(location: state.columnIndex, length: 1)) + + needsDisplay = true } - override var backgroundStyle: NSView.BackgroundStyle { - didSet { - let nextEmphasized = backgroundStyle == .emphasized - guard nextEmphasized != onEmphasizedSelection else { return } - onEmphasizedSelection = nextEmphasized - needsDisplay = true - updateFocusPresentation() - updateAccessoryTint() + private func currentEmphasizedSelection() -> Bool { + var view: NSView? = superview + while let candidate = view { + if let row = candidate as? NSTableRowView { + return row.isSelected && row.isEmphasized + } + view = candidate.superview } + return false + } + + override func viewWillDraw() { + super.viewWillDraw() + let nextEmphasized = currentEmphasizedSelection() + guard nextEmphasized != onEmphasizedSelection else { return } + onEmphasizedSelection = nextEmphasized + attributedCache = nil + updateFocusPresentation() } private func updateFocusPresentation() { @@ -262,101 +195,131 @@ final class DataGridCellView: NSTableCellView { NSBezierPath(rect: bounds).fill() } + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + needsDisplay = true + } + override func draw(_ dirtyRect: NSRect) { if let tint = modifiedColumnTint, !onEmphasizedSelection { tint.setFill() bounds.fill() } - drawFocusBorderIfNeeded() - } - override func setFrameSize(_ newSize: NSSize) { - super.setFrameSize(newSize) - needsDisplay = true - } + let accessoryRect = computeAccessoryRect() + accessoryHitRect = accessoryRect - private func drawFocusBorderIfNeeded() { - guard isFocusedCell, onEmphasizedSelection else { return } - let path = NSBezierPath(rect: bounds.insetBy(dx: 1, dy: 1)) - path.lineWidth = 2 - NSColor.alternateSelectedControlTextColor.setStroke() - path.stroke() - } + drawText(reservingTrailingWidth: accessoryRect.width) + drawAccessory(in: accessoryRect) - private func configureAccessoryButton() { - guard accessoryVisible else { - if !accessoryButton.isHidden { - accessoryButton.isHidden = true - } - return + if isFocusedCell && onEmphasizedSelection { + drawFocusBorder() } - let (image, size, label) = accessoryAssets() - accessoryButton.image = image - accessoryButton.setAccessibilityLabel(label) - accessoryWidthConstraint.constant = size.width - accessoryHeightConstraint.constant = size.height - accessoryButton.isHidden = false - updateAccessoryTint() } - private func accessoryAssets() -> (NSImage, NSSize, String) { - switch kind { - case .foreignKey: - return ( - Self.fkSymbol, - NSSize(width: 16, height: 16), - String(localized: "Navigate to referenced row") - ) - case .text: - return (NSImage(), .zero, "") - case .dropdown, .boolean, .date, .json, .blob: - return ( - Self.chevronSymbol, - NSSize(width: 12, height: 14), - String(localized: "Open editor") - ) - } + private func drawText(reservingTrailingWidth trailing: CGFloat) { + guard !displayText.isEmpty else { return } + let attr = cachedAttributedString() + var rect = bounds.insetBy(dx: DataGridMetrics.cellHorizontalInset, dy: 0) + rect.size.width -= trailing + guard rect.width > 0 else { return } + let lineHeight = textFont.ascender - textFont.descender + textFont.leading + rect.origin.y = max(0, (bounds.height - lineHeight) / 2) + rect.size.height = lineHeight + 2 + attr.draw(with: rect, options: [.truncatesLastVisibleLine, .usesLineFragmentOrigin], context: nil) } - private func updateAccessoryTint() { - accessoryButton.contentTintColor = onEmphasizedSelection - ? .alternateSelectedControlTextColor - : .secondaryLabelColor + private func resolvedTextColor() -> NSColor { + onEmphasizedSelection ? .alternateSelectedControlTextColor : textColor } - private func trailingInset(for accessoryVisible: Bool) -> CGFloat { - guard accessoryVisible else { return -DataGridMetrics.cellHorizontalInset } - switch kind { - case .foreignKey: return -22 - case .text: return -DataGridMetrics.cellHorizontalInset - case .dropdown, .boolean, .date, .json, .blob: return -18 + private func cachedAttributedString() -> NSAttributedString { + if let cached = attributedCache { return cached } + let textNS = displayText as NSString + let truncated: String + if textNS.length > 300 { + truncated = textNS.substring(to: 300) + "\u{2026}" + } else { + truncated = displayText } + let attrs: [NSAttributedString.Key: Any] = [ + .font: textFont, + .foregroundColor: resolvedTextColor(), + .paragraphStyle: Self.placeholderParagraph + ] + let str = NSAttributedString(string: truncated, attributes: attrs) + attributedCache = str + return str } - private func computeAccessoryVisibility( - content: DataGridCellContent, - state: DataGridCellState - ) -> Bool { + private func computeAccessoryRect() -> NSRect { switch kind { - case .foreignKey: - guard let raw = content.rawValue, !raw.isEmpty else { return false } - return true case .text: - return false + return .zero + case .foreignKey: + guard let raw = rawValue, !raw.isEmpty else { return .zero } + let size = NSSize(width: 16, height: 16) + let x = bounds.maxX - DataGridMetrics.cellHorizontalInset - size.width + let y = (bounds.height - size.height) / 2 + return NSRect(x: x, y: y, width: size.width, height: size.height) case .dropdown, .boolean, .date, .json, .blob: - return state.isEditable && !state.visualState.isDeleted + guard isEditableCell, !visualState.isDeleted else { return .zero } + let size = NSSize(width: 12, height: 14) + let x = bounds.maxX - DataGridMetrics.cellHorizontalInset - size.width + let y = (bounds.height - size.height) / 2 + return NSRect(x: x, y: y, width: size.width, height: size.height) } } - @objc private func handleAccessoryClick(_ sender: NSButton) { + private func drawAccessory(in rect: NSRect) { + guard !rect.isEmpty else { return } + let image: NSImage switch kind { - case .foreignKey: - accessoryDelegate?.dataGridCellDidClickFKArrow(row: cellRow, columnIndex: cellColumnIndex) case .text: return + case .foreignKey: + image = onEmphasizedSelection ? Self.fkArrowEmphasized : Self.fkArrowNormal case .dropdown, .boolean, .date, .json, .blob: - accessoryDelegate?.dataGridCellDidClickChevron(row: cellRow, columnIndex: cellColumnIndex) + image = onEmphasizedSelection ? Self.chevronEmphasized : Self.chevronNormal + } + image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1.0, respectFlipped: true, hints: nil) + } + + private func drawFocusBorder() { + let path = NSBezierPath(rect: bounds.insetBy(dx: 1, dy: 1)) + path.lineWidth = 2 + NSColor.alternateSelectedControlTextColor.setStroke() + path.stroke() + } + + override func mouseDown(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + if !accessoryHitRect.isEmpty && accessoryHitRect.contains(point) { + switch kind { + case .foreignKey: + accessoryDelegate?.dataGridCellDidClickFKArrow(row: cellRow, columnIndex: cellColumnIndex) + return + case .dropdown, .boolean, .date, .json, .blob: + accessoryDelegate?.dataGridCellDidClickChevron(row: cellRow, columnIndex: cellColumnIndex) + return + case .text: + break + } + } + super.mouseDown(with: event) + } + + override func rightMouseDown(with event: NSEvent) { + var view: NSView? = self + while let parent = view?.superview { + if let rowView = parent as? DataGridRowView, + let menu = rowView.menu(for: event) { + NSMenu.popUpContextMenu(menu, with: event, for: self) + return + } + view = parent } + super.rightMouseDown(with: event) } private func colorsEqual(_ lhs: NSColor?, _ rhs: NSColor?) -> Bool { diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 15180ce6a..02aad0176 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -6,7 +6,7 @@ import SwiftUI @MainActor final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewDataSource, - NSControlTextEditingDelegate, NSTextFieldDelegate, NSMenuDelegate + NSMenuDelegate { var tableRowsProvider: @MainActor () -> TableRows = { TableRows() } var tableRowsMutator: @MainActor (@MainActor (inout TableRows) -> Void) -> Void = { _ in } @@ -136,7 +136,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData self.cellRegistry = DataGridCellRegistry() super.init() cellRegistry.accessoryDelegate = self - cellRegistry.textFieldDelegate = self updateCache() observeThemeChanges() @@ -465,11 +464,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } func commitActiveCellEdit() { + overlayEditor?.dismiss(commit: true) guard let tableView, let window = tableView.window else { return } - if tableView.editedRow >= 0 { - window.makeFirstResponder(tableView) - return - } if let firstResponder = window.firstResponder as? NSView, firstResponder.isDescendant(of: tableView) { window.makeFirstResponder(tableView) @@ -483,7 +479,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData guard displayRow >= 0, displayRow < tableView.numberOfRows else { return } tableView.scrollRowToVisible(displayRow) tableView.selectRowIndexes(IndexSet(integer: displayRow), byExtendingSelection: false) - tableView.editColumn(displayCol, row: displayRow, with: nil, select: true) + beginCellEdit(row: displayRow, tableColumnIndex: displayCol) } func refreshForeignKeyColumns() { diff --git a/TablePro/Views/Results/DataGridRowView.swift b/TablePro/Views/Results/DataGridRowView.swift index 03de645f6..c74996850 100644 --- a/TablePro/Views/Results/DataGridRowView.swift +++ b/TablePro/Views/Results/DataGridRowView.swift @@ -52,6 +52,26 @@ final class DataGridRowView: NSTableRowView { } } + override var isSelected: Bool { + didSet { + guard isSelected != oldValue else { return } + invalidateCellSubviews() + } + } + + override var isEmphasized: Bool { + didSet { + guard isEmphasized != oldValue else { return } + invalidateCellSubviews() + } + } + + private func invalidateCellSubviews() { + for subview in subviews where subview is DataGridCellView { + subview.needsDisplay = true + } + } + override func drawBackground(in dirtyRect: NSRect) { super.drawBackground(in: dirtyRect) guard let rowTint, !isSelected else { return } diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index b2096b918..1fffd652f 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -69,7 +69,6 @@ struct DataGridView: NSViewRepresentable { tableView.delegate = context.coordinator tableView.dataSource = context.coordinator tableView.target = context.coordinator - tableView.action = #selector(TableViewCoordinator.handleClick(_:)) tableView.doubleAction = #selector(TableViewCoordinator.handleDoubleClick(_:)) let rowNumberColumn = Self.makeRowNumberColumn() @@ -362,7 +361,7 @@ struct DataGridView: NSViewRepresentable { } static func dismantleNSView(_ nsView: NSScrollView, coordinator: TableViewCoordinator) { - coordinator.overlayEditor?.dismiss(commit: false) + coordinator.overlayEditor?.dismiss(commit: true) coordinator.persistColumnLayoutToStorage() coordinator.settingsCancellable = nil coordinator.themeCancellable = nil diff --git a/TablePro/Views/Results/Extensions/DataGridView+Click.swift b/TablePro/Views/Results/Extensions/DataGridView+Click.swift index 5e567ef97..7a0d5b260 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Click.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Click.swift @@ -9,16 +9,6 @@ import SwiftUI extension TableViewCoordinator { // MARK: - Click Handlers - @objc func handleClick(_ sender: NSTableView) { - guard isEditable else { return } - - let row = sender.clickedRow - let column = sender.clickedColumn - guard row >= 0, column > 0 else { return } - guard DataGridView.dataColumnIndex(for: column, in: sender, schema: identitySchema) != nil else { return } - guard !changeManager.isRowDeleted(row) else { return } - } - @objc func handleDoubleClick(_ sender: NSTableView) { guard isEditable else { return } @@ -62,7 +52,7 @@ extension TableViewCoordinator { return } - sender.editColumn(column, row: row, with: nil, select: true) + beginCellEdit(row: row, tableColumnIndex: column) } // MARK: - Chevron Click diff --git a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift index 5c72bd491..c06105fbc 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift @@ -7,22 +7,19 @@ import AppKit import SwiftUI extension TableViewCoordinator { - enum InlineEditEligibility { - case eligible - case needsOverlayEditor(value: String) + enum EditEligibility { + case editable(value: String) case blocked } - func inlineEditEligibility(row: Int, columnIndex: Int) -> InlineEditEligibility { + func editEligibility(row: Int, columnIndex: Int) -> EditEligibility { guard isEditable else { return .blocked } let tableRows = tableRowsProvider() guard row >= 0, columnIndex >= 0, columnIndex < tableRows.columns.count else { return .blocked } guard !changeManager.isRowDeleted(row) else { return .blocked } let immutable = databaseType.map { PluginManager.shared.immutableColumns(for: $0) } ?? [] - if immutable.contains(tableRows.columns[columnIndex]) { - return .blocked - } + if immutable.contains(tableRows.columns[columnIndex]) { return .blocked } let columnName = tableRows.columns[columnIndex] if tableRows.columnForeignKeys[columnName] != nil { return .blocked } @@ -38,42 +35,45 @@ extension TableViewCoordinator { if dropdownColumns?.contains(columnIndex) == true { return .blocked } if typePickerColumns?.contains(columnIndex) == true { return .blocked } + let value: String if let displayRow = displayRow(at: row), columnIndex < displayRow.values.count, - let value = displayRow.values[columnIndex] { - if value.containsLineBreak { return .needsOverlayEditor(value: value) } - if value.looksLikeJson { return .blocked } + let raw = displayRow.values[columnIndex] { + value = raw + } else { + value = "" } - - return .eligible + return .editable(value: value) } func canStartInlineEdit(row: Int, columnIndex: Int) -> Bool { - if case .eligible = inlineEditEligibility(row: row, columnIndex: columnIndex) { + if case .editable = editEligibility(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 != ColumnIdentitySchema.rowNumberIdentifier else { return false } - guard let columnIndex = dataColumnIndex(from: tableColumn.identifier) else { return false } + 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 - } + func beginCellEdit(row: Int, tableColumnIndex: Int) { + guard let tableView else { return } + guard tableColumnIndex >= 0, tableColumnIndex < tableView.numberOfColumns else { return } + let column = tableView.tableColumns[tableColumnIndex] + guard column.identifier != ColumnIdentitySchema.rowNumberIdentifier else { return } + guard let columnIndex = dataColumnIndex(from: column.identifier) else { return } + guard case .editable(let value) = editEligibility(row: row, columnIndex: columnIndex) else { return } + showOverlayEditor( + tableView: tableView, + row: row, + column: tableColumnIndex, + columnIndex: columnIndex, + value: value + ) } - // MARK: - Overlay Editor (Multiline) + // MARK: - Overlay Editor func showOverlayEditor(tableView: NSTableView, row: Int, column: Int, columnIndex: Int, value: String) { if overlayEditor == nil { @@ -82,7 +82,7 @@ extension TableViewCoordinator { guard let editor = overlayEditor else { return } editor.onCommit = { [weak self] row, columnIndex, newValue in - self?.commitOverlayEdit(row: row, columnIndex: columnIndex, newValue: newValue) + self?.commitCellEdit(row: row, columnIndex: columnIndex, newValue: newValue) } editor.onTabNavigation = { [weak self] row, column, forward in self?.handleOverlayTabNavigation(row: row, column: column, forward: forward) @@ -90,10 +90,6 @@ extension TableViewCoordinator { editor.show(in: tableView, row: row, column: column, columnIndex: columnIndex, value: value) } - func commitOverlayEdit(row: Int, columnIndex: Int, newValue: String) { - commitCellEdit(row: row, columnIndex: columnIndex, newValue: newValue) - } - func handleOverlayTabNavigation(row: Int, column: Int, forward: Bool) { guard let tableView = tableView else { return } @@ -122,123 +118,21 @@ extension TableViewCoordinator { tableView.selectRowIndexes(IndexSet(integer: nextRow), byExtendingSelection: false) - if let nextColumnIndex = DataGridView.dataColumnIndex( - for: nextColumn, - in: tableView, - schema: identitySchema - ), - nextColumnIndex >= 0, - let nextDisplayRow = displayRow(at: nextRow), - nextColumnIndex < nextDisplayRow.values.count, - let value = nextDisplayRow.values[nextColumnIndex], - value.containsLineBreak { - showOverlayEditor(tableView: tableView, row: nextRow, column: nextColumn, columnIndex: nextColumnIndex, value: value) - } else { - tableView.editColumn(nextColumn, row: nextRow, with: nil, select: true) - } - } - - func control(_ control: NSControl, textShouldEndEditing fieldEditor: NSText) -> Bool { - guard let textField = control as? NSTextField, let tableView = tableView else { return true } - - let row = tableView.row(for: textField) - let column = tableView.column(for: textField) - - guard row >= 0, column > 0, - let columnIndex = DataGridView.dataColumnIndex( - for: column, + guard let nextColumnIndex = DataGridView.dataColumnIndex( + for: nextColumn, in: tableView, schema: identitySchema - ) else { return true } - - if isEscapeCancelling { - isEscapeCancelling = false - let originalValue: String? = { - guard let displayRow = displayRow(at: row), columnIndex < displayRow.values.count else { return nil } - return displayRow.values[columnIndex] - }() - textField.stringValue = originalValue ?? "" - (control as? CellTextField)?.restoreTruncatedDisplay() - return true - } - - let rawInput = textField.stringValue - let oldValue: String? = { - guard let displayRow = displayRow(at: row), columnIndex < displayRow.values.count else { return nil } - return displayRow.values[columnIndex] - }() - let newValue: String? = rawInput.isEmpty && oldValue == nil ? nil : rawInput - - commitCellEdit(row: row, columnIndex: columnIndex, newValue: newValue) - - (control as? CellTextField)?.restoreTruncatedDisplay() - - return true - } - - func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { - guard let tableView = tableView else { return false } - - let currentRow = tableView.row(for: control) - let currentColumn = tableView.column(for: control) - - guard currentRow >= 0, currentColumn >= 0 else { return false } - - if commandSelector == #selector(NSResponder.insertTab(_:)) { - tableView.window?.makeFirstResponder(tableView) - - var nextColumn = currentColumn + 1 - var nextRow = currentRow - - if nextColumn >= tableView.numberOfColumns { - nextColumn = 1 - nextRow += 1 - } - if nextRow >= tableView.numberOfRows { - nextRow = tableView.numberOfRows - 1 - nextColumn = tableView.numberOfColumns - 1 - } - - Task { @MainActor in - tableView.selectRowIndexes(IndexSet(integer: nextRow), byExtendingSelection: false) - tableView.editColumn(nextColumn, row: nextRow, with: nil, select: true) - } - return true - } - - if commandSelector == #selector(NSResponder.insertBacktab(_:)) { - tableView.window?.makeFirstResponder(tableView) - - var prevColumn = currentColumn - 1 - var prevRow = currentRow - - if prevColumn < 1 { - prevColumn = tableView.numberOfColumns - 1 - prevRow -= 1 - } - if prevRow < 0 { - prevRow = 0 - prevColumn = 1 - } - - Task { @MainActor in - tableView.selectRowIndexes(IndexSet(integer: prevRow), byExtendingSelection: false) - tableView.editColumn(prevColumn, row: prevRow, with: nil, select: true) - } - return true - } - - if commandSelector == #selector(NSResponder.insertNewline(_:)) { - tableView.window?.makeFirstResponder(tableView) - return true - } - - if commandSelector == #selector(NSResponder.cancelOperation(_:)) { - isEscapeCancelling = true - tableView.window?.makeFirstResponder(tableView) - return true - } - - return false + ), + nextColumnIndex >= 0, + case .editable(let value) = editEligibility(row: nextRow, columnIndex: nextColumnIndex) + else { return } + + showOverlayEditor( + tableView: tableView, + row: nextRow, + column: nextColumn, + columnIndex: nextColumnIndex, + value: value + ) } } diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 87c4c2b38..eb0a35a64 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -90,7 +90,7 @@ final class KeyHandlingTableView: NSTableView { if let schema = coordinator?.identitySchema, let dataColumnIndex = DataGridView.dataColumnIndex(for: clickedColumn, in: self, schema: schema), coordinator?.canStartInlineEdit(row: clickedRow, columnIndex: dataColumnIndex) == true { - editColumn(clickedColumn, row: clickedRow, with: nil, select: true) + coordinator?.beginCellEdit(row: clickedRow, tableColumnIndex: clickedColumn) } } } @@ -210,7 +210,7 @@ final class KeyHandlingTableView: NSTableView { return } - editColumn(focusedColumn, row: row, with: nil, select: true) + coordinator?.beginCellEdit(row: row, tableColumnIndex: focusedColumn) } @objc override func cancelOperation(_ sender: Any?) { diff --git a/TablePro/Views/Results/SortableHeaderView.swift b/TablePro/Views/Results/SortableHeaderView.swift index 6dc14af83..6f537c89f 100644 --- a/TablePro/Views/Results/SortableHeaderView.swift +++ b/TablePro/Views/Results/SortableHeaderView.swift @@ -64,9 +64,11 @@ final class SortableHeaderView: NSTableHeaderView { weak var coordinator: TableViewCoordinator? private static let clickDragThreshold: CGFloat = 4 + private static let resizeZoneWidth: CGFloat = 4 private var pendingClickStartLocation: NSPoint? private var dragOccurredDuringClick = false + private var mouseMovedTrackingArea: NSTrackingArea? override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -76,6 +78,40 @@ final class SortableHeaderView: NSTableHeaderView { super.init(coder: coder) } + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let existing = mouseMovedTrackingArea { + removeTrackingArea(existing) + } + let area = NSTrackingArea( + rect: bounds, + options: [.activeInKeyWindow, .mouseMoved, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(area) + mouseMovedTrackingArea = area + } + + override func mouseMoved(with event: NSEvent) { + guard let tableView else { + super.mouseMoved(with: event) + return + } + let point = convert(event.locationInWindow, from: nil) + let zone = Self.resizeZoneWidth + let inResizeZone = tableView.tableColumns.enumerated().contains { index, column in + guard column.resizingMask.contains(.userResizingMask) else { return false } + let edge = headerRect(ofColumn: index).maxX + return abs(point.x - edge) <= zone + } + if inResizeZone { + NSCursor.resizeLeftRight.set() + } else { + NSCursor.arrow.set() + } + } + func updateSortIndicators(state: SortState, schema: ColumnIdentitySchema) { guard let tableView = tableView else { return }