|
| 1 | +// |
| 2 | +// TextView+ItemBox.swift |
| 3 | +// CodeEditTextView |
| 4 | +// |
| 5 | +// Created by Abe Malla on 6/18/24. |
| 6 | +// |
| 7 | + |
| 8 | +import AppKit |
| 9 | +import SwiftUI |
| 10 | + |
| 11 | +public protocol ItemBoxEntry { |
| 12 | + var view: NSView { get } |
| 13 | +} |
| 14 | + |
| 15 | +public final class ItemBoxWindowController: NSWindowController { |
| 16 | + |
| 17 | + public static let DEFAULT_SIZE = NSSize(width: 300, height: 212) |
| 18 | + |
| 19 | + public var items: [any ItemBoxEntry] = [] { |
| 20 | + didSet { |
| 21 | + updateItems() |
| 22 | + } |
| 23 | + } |
| 24 | + |
| 25 | + private let tableView = NSTableView() |
| 26 | + private let scrollView = NSScrollView() |
| 27 | + private var localEventMonitor: Any? |
| 28 | + |
| 29 | + public var isVisible: Bool { |
| 30 | + return window?.isVisible ?? false |
| 31 | + } |
| 32 | + |
| 33 | + public init() { |
| 34 | + let window = NSWindow( |
| 35 | + contentRect: NSRect(origin: CGPoint.zero, size: ItemBoxWindowController.DEFAULT_SIZE), |
| 36 | + styleMask: [.resizable, .fullSizeContentView, .nonactivatingPanel], |
| 37 | + backing: .buffered, |
| 38 | + defer: false |
| 39 | + ) |
| 40 | + |
| 41 | + // Style window |
| 42 | + window.titleVisibility = .hidden |
| 43 | + window.titlebarAppearsTransparent = true |
| 44 | + window.isExcludedFromWindowsMenu = true |
| 45 | + window.isReleasedWhenClosed = false |
| 46 | + window.level = .popUpMenu |
| 47 | + window.hasShadow = true |
| 48 | + window.isOpaque = false |
| 49 | + window.tabbingMode = .disallowed |
| 50 | + window.hidesOnDeactivate = true |
| 51 | + window.backgroundColor = .clear |
| 52 | + window.minSize = ItemBoxWindowController.DEFAULT_SIZE |
| 53 | + |
| 54 | + // Style the content with custom borders and colors |
| 55 | + window.contentView?.wantsLayer = true |
| 56 | + window.contentView?.layer?.backgroundColor = CGColor( |
| 57 | + srgbRed: 31.0 / 255.0, green: 31.0 / 255.0, blue: 36.0 / 255.0, alpha: 1.0 |
| 58 | + ) |
| 59 | + window.contentView?.layer?.cornerRadius = 8 |
| 60 | + window.contentView?.layer?.borderWidth = 1 |
| 61 | + window.contentView?.layer?.borderColor = NSColor.gray.withAlphaComponent(0.4).cgColor |
| 62 | + let innerShadow = NSShadow() |
| 63 | + innerShadow.shadowColor = NSColor.black.withAlphaComponent(0.1) |
| 64 | + innerShadow.shadowOffset = NSSize(width: 0, height: -1) |
| 65 | + innerShadow.shadowBlurRadius = 2 |
| 66 | + window.contentView?.shadow = innerShadow |
| 67 | + |
| 68 | + super.init(window: window) |
| 69 | + |
| 70 | + setupTableView() |
| 71 | + } |
| 72 | + |
| 73 | + required init?(coder: NSCoder) { |
| 74 | + fatalError("init(coder:) has not been implemented") |
| 75 | + } |
| 76 | + |
| 77 | + /// Opens the window of items |
| 78 | + public func show() { |
| 79 | + super.showWindow(nil) |
| 80 | + setupEventMonitor() |
| 81 | + } |
| 82 | + |
| 83 | + public func showWindow(attachedTo parentWindow: NSWindow) { |
| 84 | + guard let window = self.window else { return } |
| 85 | + parentWindow.addChildWindow(window, ordered: .above) |
| 86 | + window.orderFront(nil) |
| 87 | + |
| 88 | + // Close on window switch |
| 89 | + NotificationCenter.default.addObserver( |
| 90 | + forName: NSWindow.didResignKeyNotification, |
| 91 | + object: parentWindow, |
| 92 | + queue: .current |
| 93 | + ) { [weak self] _ in |
| 94 | + self?.close() |
| 95 | + } |
| 96 | + |
| 97 | + self.show() |
| 98 | + } |
| 99 | + |
| 100 | + public override func close() { |
| 101 | + guard isVisible else { return } |
| 102 | + removeEventMonitor() |
| 103 | + super.close() |
| 104 | + } |
| 105 | + |
| 106 | + private func setupTableView() { |
| 107 | + tableView.delegate = self |
| 108 | + tableView.dataSource = self |
| 109 | + tableView.headerView = nil |
| 110 | + tableView.backgroundColor = .clear |
| 111 | + tableView.intercellSpacing = .zero |
| 112 | + tableView.selectionHighlightStyle = .none |
| 113 | + tableView.backgroundColor = .clear |
| 114 | + tableView.enclosingScrollView?.drawsBackground = false |
| 115 | + tableView.rowHeight = 24 |
| 116 | + |
| 117 | + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("ItemsCell")) |
| 118 | + column.width = ItemBoxWindowController.DEFAULT_SIZE.width |
| 119 | + tableView.addTableColumn(column) |
| 120 | + |
| 121 | + scrollView.documentView = tableView |
| 122 | + scrollView.hasVerticalScroller = true |
| 123 | + scrollView.verticalScroller?.controlSize = .large |
| 124 | + scrollView.autohidesScrollers = true |
| 125 | + scrollView.automaticallyAdjustsContentInsets = false |
| 126 | + scrollView.contentInsets = NSEdgeInsetsZero |
| 127 | + window?.contentView?.addSubview(scrollView) |
| 128 | + |
| 129 | + scrollView.translatesAutoresizingMaskIntoConstraints = false |
| 130 | + NSLayoutConstraint.activate([ |
| 131 | + scrollView.topAnchor.constraint(equalTo: window!.contentView!.topAnchor), |
| 132 | + scrollView.leadingAnchor.constraint(equalTo: window!.contentView!.leadingAnchor), |
| 133 | + scrollView.trailingAnchor.constraint(equalTo: window!.contentView!.trailingAnchor), |
| 134 | + scrollView.bottomAnchor.constraint(equalTo: window!.contentView!.bottomAnchor) |
| 135 | + ]) |
| 136 | + } |
| 137 | + |
| 138 | + private func updateItems() { |
| 139 | + tableView.reloadData() |
| 140 | + } |
| 141 | + |
| 142 | + public func tableViewSelectionDidChange(_ notification: Notification) { |
| 143 | + tableView.enumerateAvailableRowViews { (rowView, row) in |
| 144 | + if let cellView = rowView.view(atColumn: 0) as? CustomTableCellView { |
| 145 | + cellView.backgroundStyle = tableView.selectedRow == row ? .emphasized : .normal |
| 146 | + } |
| 147 | + } |
| 148 | + } |
| 149 | + |
| 150 | + private func setupEventMonitor() { |
| 151 | + localEventMonitor = NSEvent.addLocalMonitorForEvents( |
| 152 | + matching: [.keyDown, .leftMouseDown, .rightMouseDown] |
| 153 | + ) { [weak self] event in |
| 154 | + guard let self = self else { return event } |
| 155 | + |
| 156 | + switch event.type { |
| 157 | + case .keyDown: |
| 158 | + switch event.keyCode { |
| 159 | + case 53: // Escape key |
| 160 | + self.close() |
| 161 | + case 125: // Down arrow |
| 162 | + self.selectNextItemInTable() |
| 163 | + return nil |
| 164 | + case 126: // Up arrow |
| 165 | + self.selectPreviousItemInTable() |
| 166 | + return nil |
| 167 | + case 36: // Return key |
| 168 | + return nil |
| 169 | + default: |
| 170 | + break |
| 171 | + } |
| 172 | + case .leftMouseDown, .rightMouseDown: |
| 173 | + // If we click outside the window, close the window |
| 174 | + if !NSMouseInRect(NSEvent.mouseLocation, self.window!.frame, false) { |
| 175 | + self.close() |
| 176 | + } |
| 177 | + default: |
| 178 | + break |
| 179 | + } |
| 180 | + |
| 181 | + return event |
| 182 | + } |
| 183 | + } |
| 184 | + |
| 185 | + private func removeEventMonitor() { |
| 186 | + if let monitor = localEventMonitor { |
| 187 | + NSEvent.removeMonitor(monitor) |
| 188 | + localEventMonitor = nil |
| 189 | + } |
| 190 | + } |
| 191 | + |
| 192 | + private func selectNextItemInTable() { |
| 193 | + let nextIndex = min(tableView.selectedRow + 1, items.count - 1) |
| 194 | + tableView.selectRowIndexes(IndexSet(integer: nextIndex), byExtendingSelection: false) |
| 195 | + tableView.scrollRowToVisible(nextIndex) |
| 196 | + } |
| 197 | + |
| 198 | + private func selectPreviousItemInTable() { |
| 199 | + let previousIndex = max(tableView.selectedRow - 1, 0) |
| 200 | + tableView.selectRowIndexes(IndexSet(integer: previousIndex), byExtendingSelection: false) |
| 201 | + tableView.scrollRowToVisible(previousIndex) |
| 202 | + } |
| 203 | + |
| 204 | + deinit { |
| 205 | + removeEventMonitor() |
| 206 | + } |
| 207 | +} |
| 208 | + |
| 209 | +extension ItemBoxWindowController: NSTableViewDataSource { |
| 210 | + public func numberOfRows(in tableView: NSTableView) -> Int { |
| 211 | + return items.count |
| 212 | + } |
| 213 | +} |
| 214 | + |
| 215 | +extension ItemBoxWindowController: NSTableViewDelegate { |
| 216 | +// public func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { |
| 217 | +// items[row].view |
| 218 | +// } |
| 219 | + |
| 220 | + public func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { |
| 221 | + let cellIdentifier = NSUserInterfaceItemIdentifier("CustomCell") |
| 222 | + var cell = tableView.makeView(withIdentifier: cellIdentifier, owner: nil) as? CustomTableCellView |
| 223 | + |
| 224 | + if cell == nil { |
| 225 | + cell = CustomTableCellView(frame: .zero) |
| 226 | + cell?.identifier = cellIdentifier |
| 227 | + } |
| 228 | + |
| 229 | + // Remove any existing subviews |
| 230 | + cell?.subviews.forEach { $0.removeFromSuperview() } |
| 231 | + |
| 232 | + let itemView = items[row].view |
| 233 | + cell?.addSubview(itemView) |
| 234 | + itemView.translatesAutoresizingMaskIntoConstraints = false |
| 235 | + NSLayoutConstraint.activate([ |
| 236 | + itemView.topAnchor.constraint(equalTo: cell!.topAnchor), |
| 237 | + itemView.leadingAnchor.constraint(equalTo: cell!.leadingAnchor, constant: 4), |
| 238 | + itemView.trailingAnchor.constraint(equalTo: cell!.trailingAnchor, constant: -4), |
| 239 | + itemView.bottomAnchor.constraint(equalTo: cell!.bottomAnchor) |
| 240 | + ]) |
| 241 | + |
| 242 | + return cell |
| 243 | + } |
| 244 | +} |
| 245 | + |
| 246 | +private class CustomTableCellView: NSTableCellView { |
| 247 | + private let backgroundView = NSView() |
| 248 | + |
| 249 | + override init(frame frameRect: NSRect) { |
| 250 | + super.init(frame: frameRect) |
| 251 | + setup() |
| 252 | + } |
| 253 | + |
| 254 | + required init?(coder: NSCoder) { |
| 255 | + fatalError("init(coder:) has not been implemented") |
| 256 | + } |
| 257 | + |
| 258 | + private func setup() { |
| 259 | + wantsLayer = true |
| 260 | + layerContentsRedrawPolicy = .onSetNeedsDisplay |
| 261 | + |
| 262 | + backgroundView.wantsLayer = true |
| 263 | + backgroundView.layer?.cornerRadius = 4 |
| 264 | + addSubview(backgroundView, positioned: .below, relativeTo: nil) |
| 265 | + |
| 266 | + backgroundView.translatesAutoresizingMaskIntoConstraints = false |
| 267 | + NSLayoutConstraint.activate([ |
| 268 | + backgroundView.topAnchor.constraint(equalTo: topAnchor), |
| 269 | + backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor), |
| 270 | + backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor), |
| 271 | + backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor) |
| 272 | + ]) |
| 273 | + } |
| 274 | + |
| 275 | + override var backgroundStyle: NSView.BackgroundStyle { |
| 276 | + didSet { |
| 277 | + updateBackgroundColor() |
| 278 | + } |
| 279 | + } |
| 280 | + |
| 281 | + private func updateBackgroundColor() { |
| 282 | + switch backgroundStyle { |
| 283 | + case .normal: |
| 284 | + backgroundView.layer?.backgroundColor = NSColor.clear.cgColor |
| 285 | + case .emphasized: |
| 286 | + backgroundView.layer?.backgroundColor = NSColor.systemBlue.withAlphaComponent(0.5).cgColor |
| 287 | + @unknown default: |
| 288 | + backgroundView.layer?.backgroundColor = NSColor.clear.cgColor |
| 289 | + } |
| 290 | + } |
| 291 | +} |
0 commit comments