|
| 1 | +// |
| 2 | +// SuggestionController+Window.swift |
| 3 | +// CodeEditTextView |
| 4 | +// |
| 5 | +// Created by Abe Malla on 12/22/24. |
| 6 | +// |
| 7 | + |
| 8 | +import AppKit |
| 9 | + |
| 10 | +extension SuggestionController { |
| 11 | + /// Will constrain the window's frame to be within the visible screen |
| 12 | + public func constrainWindowToScreenEdges(cursorRect: NSRect) { |
| 13 | + guard let window = self.window, |
| 14 | + let screenFrame = window.screen?.visibleFrame else { |
| 15 | + return |
| 16 | + } |
| 17 | + |
| 18 | + let windowSize = window.frame.size |
| 19 | + let padding: CGFloat = 22 |
| 20 | + // TODO: PASS IN OFFSET |
| 21 | + var newWindowOrigin = NSPoint( |
| 22 | + x: cursorRect.origin.x - Self.WINDOW_PADDING - 13 - 16.5, |
| 23 | + y: cursorRect.origin.y |
| 24 | + ) |
| 25 | + |
| 26 | + // Keep the horizontal position within the screen and some padding |
| 27 | + let minX = screenFrame.minX + padding |
| 28 | + let maxX = screenFrame.maxX - windowSize.width - padding |
| 29 | + |
| 30 | + if newWindowOrigin.x < minX { |
| 31 | + newWindowOrigin.x = minX |
| 32 | + } else if newWindowOrigin.x > maxX { |
| 33 | + newWindowOrigin.x = maxX |
| 34 | + } |
| 35 | + |
| 36 | + // Check if the window will go below the screen |
| 37 | + // We determine whether the window drops down or upwards by choosing which |
| 38 | + // corner of the window we will position: `setFrameOrigin` or `setFrameTopLeftPoint` |
| 39 | + if newWindowOrigin.y - windowSize.height < screenFrame.minY { |
| 40 | + // If the cursor itself is below the screen, then position the window |
| 41 | + // at the bottom of the screen with some padding |
| 42 | + if newWindowOrigin.y < screenFrame.minY { |
| 43 | + newWindowOrigin.y = screenFrame.minY + padding |
| 44 | + } else { |
| 45 | + // Place above the cursor |
| 46 | + newWindowOrigin.y += cursorRect.height |
| 47 | + } |
| 48 | + |
| 49 | + isWindowAboveCursor = true |
| 50 | + window.setFrameOrigin(newWindowOrigin) |
| 51 | + } else { |
| 52 | + // If the window goes above the screen, position it below the screen with padding |
| 53 | + let maxY = screenFrame.maxY - padding |
| 54 | + if newWindowOrigin.y > maxY { |
| 55 | + newWindowOrigin.y = maxY |
| 56 | + } |
| 57 | + |
| 58 | + isWindowAboveCursor = false |
| 59 | + window.setFrameTopLeftPoint(newWindowOrigin) |
| 60 | + } |
| 61 | + } |
| 62 | + |
| 63 | + // MARK: - Private Methods |
| 64 | + |
| 65 | + static func makeWindow() -> NSWindow { |
| 66 | + let window = NSWindow( |
| 67 | + contentRect: NSRect(origin: .zero, size: self.DEFAULT_SIZE), |
| 68 | + styleMask: [.resizable, .fullSizeContentView, .nonactivatingPanel], |
| 69 | + backing: .buffered, |
| 70 | + defer: false |
| 71 | + ) |
| 72 | + |
| 73 | + configureWindow(window) |
| 74 | + configureWindowContent(window) |
| 75 | + return window |
| 76 | + } |
| 77 | + |
| 78 | + static func configureWindow(_ window: NSWindow) { |
| 79 | + window.titleVisibility = .hidden |
| 80 | + window.titlebarAppearsTransparent = true |
| 81 | + window.isExcludedFromWindowsMenu = true |
| 82 | + window.isReleasedWhenClosed = false |
| 83 | + window.level = .popUpMenu |
| 84 | + window.hasShadow = true |
| 85 | + window.isOpaque = false |
| 86 | + window.tabbingMode = .disallowed |
| 87 | + window.hidesOnDeactivate = true |
| 88 | + window.backgroundColor = .clear |
| 89 | + window.minSize = Self.DEFAULT_SIZE |
| 90 | + } |
| 91 | + |
| 92 | + static func configureWindowContent(_ window: NSWindow) { |
| 93 | + guard let contentView = window.contentView else { return } |
| 94 | + |
| 95 | + contentView.wantsLayer = true |
| 96 | + // TODO: GET COLOR FROM THEME |
| 97 | + contentView.layer?.backgroundColor = CGColor( |
| 98 | + srgbRed: 31.0 / 255.0, |
| 99 | + green: 31.0 / 255.0, |
| 100 | + blue: 36.0 / 255.0, |
| 101 | + alpha: 1.0 |
| 102 | + ) |
| 103 | + contentView.layer?.cornerRadius = 8.5 |
| 104 | + contentView.layer?.borderWidth = 1 |
| 105 | + contentView.layer?.borderColor = NSColor.gray.withAlphaComponent(0.45).cgColor |
| 106 | + |
| 107 | + let innerShadow = NSShadow() |
| 108 | + innerShadow.shadowColor = NSColor.black.withAlphaComponent(0.1) |
| 109 | + innerShadow.shadowOffset = NSSize(width: 0, height: -1) |
| 110 | + innerShadow.shadowBlurRadius = 2 |
| 111 | + contentView.shadow = innerShadow |
| 112 | + } |
| 113 | + |
| 114 | + func configureTableView() { |
| 115 | + tableView.delegate = self |
| 116 | + tableView.dataSource = self |
| 117 | + tableView.headerView = nil |
| 118 | + tableView.backgroundColor = .clear |
| 119 | + tableView.intercellSpacing = .zero |
| 120 | + tableView.allowsEmptySelection = false |
| 121 | + tableView.selectionHighlightStyle = .regular |
| 122 | + tableView.style = .plain |
| 123 | + tableView.usesAutomaticRowHeights = false |
| 124 | + tableView.rowSizeStyle = .custom |
| 125 | + tableView.rowHeight = 21 |
| 126 | + tableView.gridStyleMask = [] |
| 127 | + tableView.target = self |
| 128 | + tableView.action = #selector(tableViewClicked(_:)) |
| 129 | + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("ItemsCell")) |
| 130 | + tableView.addTableColumn(column) |
| 131 | + } |
| 132 | + |
| 133 | + @objc private func tableViewClicked(_ sender: Any?) { |
| 134 | + if NSApp.currentEvent?.clickCount == 2 { |
| 135 | + let row = tableView.selectedRow |
| 136 | + guard row >= 0, row < items.count else { |
| 137 | + return |
| 138 | + } |
| 139 | + let selectedItem = items[row] |
| 140 | + delegate?.applyCompletionItem(item: selectedItem) |
| 141 | + self.close() |
| 142 | + } |
| 143 | + } |
| 144 | + |
| 145 | + func configureScrollView() { |
| 146 | + scrollView.documentView = tableView |
| 147 | + scrollView.hasVerticalScroller = true |
| 148 | + scrollView.verticalScroller = NoSlotScroller() |
| 149 | + scrollView.scrollerStyle = .overlay |
| 150 | + scrollView.autohidesScrollers = true |
| 151 | + scrollView.drawsBackground = false |
| 152 | + scrollView.automaticallyAdjustsContentInsets = false |
| 153 | + scrollView.translatesAutoresizingMaskIntoConstraints = false |
| 154 | + scrollView.verticalScrollElasticity = .allowed |
| 155 | + scrollView.contentInsets = NSEdgeInsets( |
| 156 | + top: Self.WINDOW_PADDING, |
| 157 | + left: 0, |
| 158 | + bottom: Self.WINDOW_PADDING, |
| 159 | + right: 0 |
| 160 | + ) |
| 161 | + |
| 162 | + guard let contentView = window?.contentView else { return } |
| 163 | + contentView.addSubview(scrollView) |
| 164 | + |
| 165 | + NSLayoutConstraint.activate([ |
| 166 | + scrollView.topAnchor.constraint(equalTo: contentView.topAnchor), |
| 167 | + scrollView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), |
| 168 | + scrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), |
| 169 | + scrollView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) |
| 170 | + ]) |
| 171 | + } |
| 172 | + |
| 173 | + /// Updates the item box window's height based on the number of items. |
| 174 | + /// If there are no items, the default label will be displayed instead. |
| 175 | + func updateSuggestionWindowAndContents() { |
| 176 | + guard let window = self.window else { |
| 177 | + return |
| 178 | + } |
| 179 | + |
| 180 | + noItemsLabel.isHidden = !items.isEmpty |
| 181 | + scrollView.isHidden = items.isEmpty |
| 182 | + |
| 183 | + // Update window dimensions |
| 184 | + let numberOfVisibleRows = min(CGFloat(items.count), Self.MAX_VISIBLE_ROWS) |
| 185 | + let newHeight = items.count == 0 ? |
| 186 | + Self.rowsToWindowHeight(for: 1) : // Height for 1 row when empty |
| 187 | + Self.rowsToWindowHeight(for: numberOfVisibleRows) |
| 188 | + |
| 189 | + let currentFrame = window.frame |
| 190 | + if isWindowAboveCursor { |
| 191 | + // When window is above cursor, maintain the bottom position |
| 192 | + let bottomY = currentFrame.minY |
| 193 | + let newFrame = NSRect( |
| 194 | + x: currentFrame.minX, |
| 195 | + y: bottomY, |
| 196 | + width: Self.DEFAULT_SIZE.width, |
| 197 | + height: newHeight |
| 198 | + ) |
| 199 | + window.setFrame(newFrame, display: true) |
| 200 | + } else { |
| 201 | + // When window is below cursor, maintain the top position |
| 202 | + window.setContentSize(NSSize(width: Self.DEFAULT_SIZE.width, height: newHeight)) |
| 203 | + } |
| 204 | + |
| 205 | + // Dont allow vertical resizing |
| 206 | + window.maxSize = NSSize(width: CGFloat.infinity, height: newHeight) |
| 207 | + window.minSize = NSSize(width: Self.DEFAULT_SIZE.width, height: newHeight) |
| 208 | + } |
| 209 | + |
| 210 | + func configureNoItemsLabel() { |
| 211 | + window?.contentView?.addSubview(noItemsLabel) |
| 212 | + |
| 213 | + NSLayoutConstraint.activate([ |
| 214 | + noItemsLabel.centerXAnchor.constraint(equalTo: window!.contentView!.centerXAnchor), |
| 215 | + noItemsLabel.centerYAnchor.constraint(equalTo: window!.contentView!.centerYAnchor) |
| 216 | + ]) |
| 217 | + } |
| 218 | + |
| 219 | + /// Calculate the window height for a given number of rows. |
| 220 | + static func rowsToWindowHeight(for numberOfRows: CGFloat) -> CGFloat { |
| 221 | + let wholeRows = floor(numberOfRows) |
| 222 | + let partialRow = numberOfRows - wholeRows |
| 223 | + |
| 224 | + let baseHeight = ROW_HEIGHT * wholeRows |
| 225 | + let partialHeight = partialRow > 0 ? ROW_HEIGHT * partialRow : 0 |
| 226 | + |
| 227 | + // Add window padding only for whole numbers |
| 228 | + let padding = numberOfRows.truncatingRemainder(dividingBy: 1) == 0 ? WINDOW_PADDING * 2 : WINDOW_PADDING |
| 229 | + |
| 230 | + return baseHeight + partialHeight + padding |
| 231 | + } |
| 232 | +} |
| 233 | + |
| 234 | +extension SuggestionController: NSTableViewDataSource, NSTableViewDelegate { |
| 235 | + public func numberOfRows(in tableView: NSTableView) -> Int { |
| 236 | + return items.count |
| 237 | + } |
| 238 | + |
| 239 | + public func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { |
| 240 | + (items[row] as? any CodeSuggestionEntry)?.view |
| 241 | + } |
| 242 | + |
| 243 | + public func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { |
| 244 | + CodeSuggestionRowView() |
| 245 | + } |
| 246 | + |
| 247 | + public func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { |
| 248 | + // Only allow selection through keyboard navigation or single clicks |
| 249 | + let event = NSApp.currentEvent |
| 250 | + if event?.type == .leftMouseDragged { |
| 251 | + return false |
| 252 | + } |
| 253 | + return true |
| 254 | + } |
| 255 | +} |
| 256 | + |
| 257 | +private class CodeSuggestionRowView: NSTableRowView { |
| 258 | + override func drawSelection(in dirtyRect: NSRect) { |
| 259 | + guard isSelected else { return } |
| 260 | + guard let context = NSGraphicsContext.current?.cgContext else { return } |
| 261 | + |
| 262 | + context.saveGState() |
| 263 | + defer { context.restoreGState() } |
| 264 | + |
| 265 | + // Create a rect that's inset from the edges and has proper padding |
| 266 | + // TODO: We create a new selectionRect instead of using dirtyRect |
| 267 | + // because there is a visual bug when holding down the arrow keys |
| 268 | + // to select the first or last item, which draws a clipped |
| 269 | + // rectangular highlight shape instead of the whole rectangle. |
| 270 | + // Replace this when it gets fixed. |
| 271 | + let selectionRect = NSRect( |
| 272 | + x: SuggestionController.WINDOW_PADDING, |
| 273 | + y: 0, |
| 274 | + width: bounds.width - (SuggestionController.WINDOW_PADDING * 2), |
| 275 | + height: bounds.height |
| 276 | + ) |
| 277 | + let cornerRadius: CGFloat = 5 |
| 278 | + let path = NSBezierPath(roundedRect: selectionRect, xRadius: cornerRadius, yRadius: cornerRadius) |
| 279 | + let selectionColor = NSColor.gray.withAlphaComponent(0.19) |
| 280 | + |
| 281 | + context.setFillColor(selectionColor.cgColor) |
| 282 | + path.fill() |
| 283 | + } |
| 284 | +} |
0 commit comments