|
| 1 | +// |
| 2 | +// CellOverlayBase.swift |
| 3 | +// TablePro |
| 4 | +// |
| 5 | + |
| 6 | +import AppKit |
| 7 | + |
| 8 | +enum CellOverlayDismissReason { |
| 9 | + case userAction |
| 10 | + case scroll |
| 11 | + case columnResize |
| 12 | + case appResign |
| 13 | + case windowResignKey |
| 14 | + case outsideClick |
| 15 | +} |
| 16 | + |
| 17 | +@MainActor |
| 18 | +class CellOverlayBase: NSObject { |
| 19 | + private var container: CellOverlayContainerView? |
| 20 | + private weak var hostTableView: NSTableView? |
| 21 | + private var scrollObserver: NSObjectProtocol? |
| 22 | + private var columnResizeObserver: NSObjectProtocol? |
| 23 | + private var appResignObserver: NSObjectProtocol? |
| 24 | + private var windowResignKeyObserver: NSObjectProtocol? |
| 25 | + private var outsideClickMonitor: Any? |
| 26 | + |
| 27 | + private(set) var row: Int = -1 |
| 28 | + private(set) var column: Int = -1 |
| 29 | + private(set) var columnIndex: Int = -1 |
| 30 | + |
| 31 | + var isActive: Bool { container != nil } |
| 32 | + var containerView: NSView? { container } |
| 33 | + var tableView: NSTableView? { hostTableView } |
| 34 | + |
| 35 | + func raiseToFront() { |
| 36 | + guard let container, let hostTableView, container.superview === hostTableView else { return } |
| 37 | + guard hostTableView.subviews.last !== container else { return } |
| 38 | + hostTableView.addSubview(container) |
| 39 | + } |
| 40 | + |
| 41 | + func install( |
| 42 | + in tableView: NSTableView, |
| 43 | + row: Int, |
| 44 | + column: Int, |
| 45 | + columnIndex: Int, |
| 46 | + container: CellOverlayContainerView |
| 47 | + ) { |
| 48 | + self.hostTableView = tableView |
| 49 | + self.row = row |
| 50 | + self.column = column |
| 51 | + self.columnIndex = columnIndex |
| 52 | + tableView.addSubview(container) |
| 53 | + self.container = container |
| 54 | + installDismissObservers() |
| 55 | + } |
| 56 | + |
| 57 | + func handleDismiss(reason: CellOverlayDismissReason) { |
| 58 | + removeOverlay() |
| 59 | + } |
| 60 | + |
| 61 | + func removeOverlay() { |
| 62 | + guard let activeContainer = container else { return } |
| 63 | + removeDismissObservers() |
| 64 | + activeContainer.removeFromSuperview() |
| 65 | + container = nil |
| 66 | + if let hostTableView { |
| 67 | + hostTableView.window?.makeFirstResponder(hostTableView) |
| 68 | + } |
| 69 | + } |
| 70 | + |
| 71 | + static func overlayFrame(for cellFrame: NSRect, value: String) -> NSRect { |
| 72 | + let lineHeight = ThemeEngine.shared.dataGridFonts.regular.boundingRectForFont.height + 4 |
| 73 | + var newlineCount = 0 |
| 74 | + for scalar in value.unicodeScalars where scalar == "\n" { |
| 75 | + newlineCount += 1 |
| 76 | + } |
| 77 | + let lineCount = CGFloat(newlineCount + 1) |
| 78 | + let contentHeight = max(lineCount * lineHeight + 8, cellFrame.height) |
| 79 | + let height = min(max(contentHeight, cellFrame.height), 120) |
| 80 | + return NSRect(x: cellFrame.origin.x, y: cellFrame.origin.y, width: cellFrame.width, height: height) |
| 81 | + } |
| 82 | + |
| 83 | + static func makeContainer(frame: NSRect) -> CellOverlayContainerView { |
| 84 | + let container = CellOverlayContainerView(frame: frame) |
| 85 | + container.wantsLayer = true |
| 86 | + container.layer?.borderWidth = 2 |
| 87 | + container.layer?.borderColor = NSColor.keyboardFocusIndicatorColor.cgColor |
| 88 | + container.layer?.cornerRadius = 2 |
| 89 | + container.layer?.masksToBounds = true |
| 90 | + container.layer?.backgroundColor = NSColor.textBackgroundColor.cgColor |
| 91 | + return container |
| 92 | + } |
| 93 | + |
| 94 | + static func makeScrollView(in container: NSView) -> NSScrollView { |
| 95 | + let scrollView = NSScrollView(frame: container.bounds) |
| 96 | + scrollView.autoresizingMask = [.width, .height] |
| 97 | + scrollView.hasVerticalScroller = true |
| 98 | + scrollView.hasHorizontalScroller = false |
| 99 | + scrollView.autohidesScrollers = true |
| 100 | + scrollView.borderType = .noBorder |
| 101 | + scrollView.drawsBackground = true |
| 102 | + scrollView.backgroundColor = .textBackgroundColor |
| 103 | + return scrollView |
| 104 | + } |
| 105 | + |
| 106 | + private func installDismissObservers() { |
| 107 | + guard let hostTableView else { return } |
| 108 | + |
| 109 | + if let clipView = hostTableView.enclosingScrollView?.contentView { |
| 110 | + scrollObserver = NotificationCenter.default.addObserver( |
| 111 | + forName: NSView.boundsDidChangeNotification, |
| 112 | + object: clipView, |
| 113 | + queue: .main |
| 114 | + ) { [weak self] _ in |
| 115 | + MainActor.assumeIsolated { |
| 116 | + self?.handleDismiss(reason: .scroll) |
| 117 | + } |
| 118 | + } |
| 119 | + } |
| 120 | + |
| 121 | + columnResizeObserver = NotificationCenter.default.addObserver( |
| 122 | + forName: NSTableView.columnDidResizeNotification, |
| 123 | + object: hostTableView, |
| 124 | + queue: .main |
| 125 | + ) { [weak self] _ in |
| 126 | + MainActor.assumeIsolated { |
| 127 | + self?.handleDismiss(reason: .columnResize) |
| 128 | + } |
| 129 | + } |
| 130 | + |
| 131 | + appResignObserver = NotificationCenter.default.addObserver( |
| 132 | + forName: NSApplication.didResignActiveNotification, |
| 133 | + object: nil, |
| 134 | + queue: .main |
| 135 | + ) { [weak self] _ in |
| 136 | + MainActor.assumeIsolated { |
| 137 | + self?.handleDismiss(reason: .appResign) |
| 138 | + } |
| 139 | + } |
| 140 | + |
| 141 | + if let overlayWindow = hostTableView.window { |
| 142 | + windowResignKeyObserver = NotificationCenter.default.addObserver( |
| 143 | + forName: NSWindow.didResignKeyNotification, |
| 144 | + object: overlayWindow, |
| 145 | + queue: .main |
| 146 | + ) { [weak self] _ in |
| 147 | + MainActor.assumeIsolated { |
| 148 | + self?.handleDismiss(reason: .windowResignKey) |
| 149 | + } |
| 150 | + } |
| 151 | + } |
| 152 | + |
| 153 | + outsideClickMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in |
| 154 | + MainActor.assumeIsolated { |
| 155 | + self?.handleOutsideClick(event: event) |
| 156 | + } |
| 157 | + return event |
| 158 | + } |
| 159 | + } |
| 160 | + |
| 161 | + private func removeDismissObservers() { |
| 162 | + if let observer = scrollObserver { |
| 163 | + NotificationCenter.default.removeObserver(observer) |
| 164 | + scrollObserver = nil |
| 165 | + } |
| 166 | + if let observer = columnResizeObserver { |
| 167 | + NotificationCenter.default.removeObserver(observer) |
| 168 | + columnResizeObserver = nil |
| 169 | + } |
| 170 | + if let observer = appResignObserver { |
| 171 | + NotificationCenter.default.removeObserver(observer) |
| 172 | + appResignObserver = nil |
| 173 | + } |
| 174 | + if let observer = windowResignKeyObserver { |
| 175 | + NotificationCenter.default.removeObserver(observer) |
| 176 | + windowResignKeyObserver = nil |
| 177 | + } |
| 178 | + if let monitor = outsideClickMonitor { |
| 179 | + NSEvent.removeMonitor(monitor) |
| 180 | + outsideClickMonitor = nil |
| 181 | + } |
| 182 | + } |
| 183 | + |
| 184 | + private func handleOutsideClick(event: NSEvent) { |
| 185 | + guard let containerView = container, |
| 186 | + let containerWindow = containerView.window, |
| 187 | + event.window === containerWindow else { return } |
| 188 | + let frameInWindow = containerView.convert(containerView.bounds, to: nil) |
| 189 | + if !frameInWindow.contains(event.locationInWindow) { |
| 190 | + handleDismiss(reason: .outsideClick) |
| 191 | + } |
| 192 | + } |
| 193 | +} |
| 194 | + |
| 195 | +final class CellOverlayContainerView: NSView { |
| 196 | + override var isFlipped: Bool { true } |
| 197 | +} |
0 commit comments