diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/NoSlotScroller.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/NoSlotScroller.swift new file mode 100644 index 000000000..9d194f28e --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/NoSlotScroller.swift @@ -0,0 +1,16 @@ +// +// NoSlotScroller.swift +// CodeEditSourceEditor +// +// Created by Abe Malla on 12/26/24. +// + +import AppKit + +class NoSlotScroller: NSScroller { + override class var isCompatibleWithOverlayScrollers: Bool { true } + + override func drawKnobSlot(in slotRect: NSRect, highlight flag: Bool) { + // Don't draw the knob slot (the background track behind the knob) + } +} diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift new file mode 100644 index 000000000..40fed2bcc --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift @@ -0,0 +1,285 @@ +// +// SuggestionController+Window.swift +// CodeEditTextView +// +// Created by Abe Malla on 12/22/24. +// + +import AppKit + +extension SuggestionController { + /// Will constrain the window's frame to be within the visible screen + public func constrainWindowToScreenEdges(cursorRect: NSRect, horizontalOffset: CGFloat) { + guard let window = self.window, + let screenFrame = window.screen?.visibleFrame else { + return + } + + let windowSize = window.frame.size + let padding: CGFloat = 22 + var newWindowOrigin = NSPoint( + x: cursorRect.origin.x - Self.WINDOW_PADDING - horizontalOffset, + y: cursorRect.origin.y + ) + + // Keep the horizontal position within the screen and some padding + let minX = screenFrame.minX + padding + let maxX = screenFrame.maxX - windowSize.width - padding + + if newWindowOrigin.x < minX { + newWindowOrigin.x = minX + } else if newWindowOrigin.x > maxX { + newWindowOrigin.x = maxX + } + + // Check if the window will go below the screen + // We determine whether the window drops down or upwards by choosing which + // corner of the window we will position: `setFrameOrigin` or `setFrameTopLeftPoint` + if newWindowOrigin.y - windowSize.height < screenFrame.minY { + // If the cursor itself is below the screen, then position the window + // at the bottom of the screen with some padding + if newWindowOrigin.y < screenFrame.minY { + newWindowOrigin.y = screenFrame.minY + padding + } else { + // Place above the cursor + newWindowOrigin.y += cursorRect.height + } + + isWindowAboveCursor = true + window.setFrameOrigin(newWindowOrigin) + } else { + // If the window goes above the screen, position it below the screen with padding + let maxY = screenFrame.maxY - padding + if newWindowOrigin.y > maxY { + newWindowOrigin.y = maxY + } + + isWindowAboveCursor = false + window.setFrameTopLeftPoint(newWindowOrigin) + } + } + + // MARK: - Private Methods + + static func makeWindow() -> NSWindow { + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: self.DEFAULT_SIZE), + styleMask: [.resizable, .fullSizeContentView, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + configureWindow(window) + configureWindowContent(window) + return window + } + + static func configureWindow(_ window: NSWindow) { + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.isExcludedFromWindowsMenu = true + window.isReleasedWhenClosed = false + window.level = .popUpMenu + window.hasShadow = true + window.isOpaque = false + window.tabbingMode = .disallowed + window.hidesOnDeactivate = true + window.backgroundColor = .clear + window.minSize = Self.DEFAULT_SIZE + } + + static func configureWindowContent(_ window: NSWindow) { + guard let contentView = window.contentView else { return } + + contentView.wantsLayer = true + // TODO: GET COLOR FROM THEME + contentView.layer?.backgroundColor = CGColor( + srgbRed: 31.0 / 255.0, + green: 31.0 / 255.0, + blue: 36.0 / 255.0, + alpha: 1.0 + ) + contentView.layer?.cornerRadius = 8.5 + contentView.layer?.borderWidth = 1 + contentView.layer?.borderColor = NSColor.gray.withAlphaComponent(0.45).cgColor + + let innerShadow = NSShadow() + innerShadow.shadowColor = NSColor.black.withAlphaComponent(0.1) + innerShadow.shadowOffset = NSSize(width: 0, height: -1) + innerShadow.shadowBlurRadius = 2 + contentView.shadow = innerShadow + } + + func configureTableView() { + tableView.delegate = self + tableView.dataSource = self + tableView.headerView = nil + tableView.backgroundColor = .clear + tableView.intercellSpacing = .zero + tableView.allowsEmptySelection = false + tableView.selectionHighlightStyle = .regular + tableView.style = .plain + tableView.usesAutomaticRowHeights = false + tableView.rowSizeStyle = .custom + tableView.rowHeight = 21 + tableView.gridStyleMask = [] + tableView.target = self + tableView.action = #selector(tableViewClicked(_:)) + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("ItemsCell")) + tableView.addTableColumn(column) + } + + @objc private func tableViewClicked(_ sender: Any?) { + if NSApp.currentEvent?.clickCount == 2 { + let row = tableView.selectedRow + guard row >= 0, row < items.count else { + return + } + let selectedItem = items[row] + delegate?.applyCompletionItem(item: selectedItem) + self.close() + } + } + + func configureScrollView() { + scrollView.documentView = tableView + scrollView.hasVerticalScroller = true + scrollView.verticalScroller = NoSlotScroller() + scrollView.scrollerStyle = .overlay + scrollView.autohidesScrollers = true + scrollView.drawsBackground = false + scrollView.automaticallyAdjustsContentInsets = false + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.verticalScrollElasticity = .allowed + scrollView.contentInsets = NSEdgeInsets( + top: Self.WINDOW_PADDING, + left: 0, + bottom: Self.WINDOW_PADDING, + right: 0 + ) + + guard let contentView = window?.contentView else { return } + contentView.addSubview(scrollView) + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: contentView.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + } + + /// Updates the item box window's height based on the number of items. + /// If there are no items, the default label will be displayed instead. + func updateSuggestionWindowAndContents() { + guard let window = self.window else { + return + } + + noItemsLabel.isHidden = !items.isEmpty + scrollView.isHidden = items.isEmpty + + // Update window dimensions + let numberOfVisibleRows = min(CGFloat(items.count), Self.MAX_VISIBLE_ROWS) + let newHeight = items.count == 0 ? + Self.rowsToWindowHeight(for: 1) : // Height for 1 row when empty + Self.rowsToWindowHeight(for: numberOfVisibleRows) + + let currentFrame = window.frame + if isWindowAboveCursor { + // When window is above cursor, maintain the bottom position + let bottomY = currentFrame.minY + let newFrame = NSRect( + x: currentFrame.minX, + y: bottomY, + width: Self.DEFAULT_SIZE.width, + height: newHeight + ) + window.setFrame(newFrame, display: true) + } else { + // When window is below cursor, maintain the top position + window.setContentSize(NSSize(width: Self.DEFAULT_SIZE.width, height: newHeight)) + } + + // Dont allow vertical resizing + window.maxSize = NSSize(width: CGFloat.infinity, height: newHeight) + window.minSize = NSSize(width: Self.DEFAULT_SIZE.width, height: newHeight) + } + + func configureNoItemsLabel() { + window?.contentView?.addSubview(noItemsLabel) + + NSLayoutConstraint.activate([ + noItemsLabel.centerXAnchor.constraint(equalTo: window!.contentView!.centerXAnchor), + noItemsLabel.centerYAnchor.constraint(equalTo: window!.contentView!.centerYAnchor) + ]) + } + + /// Calculate the window height for a given number of rows. + static func rowsToWindowHeight(for numberOfRows: CGFloat) -> CGFloat { + let wholeRows = floor(numberOfRows) + let partialRow = numberOfRows - wholeRows + + let baseHeight = ROW_HEIGHT * wholeRows + let partialHeight = partialRow > 0 ? ROW_HEIGHT * partialRow : 0 + + // Add window padding only for whole numbers + let padding = numberOfRows.truncatingRemainder(dividingBy: 1) == 0 ? WINDOW_PADDING * 2 : WINDOW_PADDING + + return baseHeight + partialHeight + padding + } +} + +extension SuggestionController: NSTableViewDataSource, NSTableViewDelegate { + public func numberOfRows(in tableView: NSTableView) -> Int { + return items.count + } + + public func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + guard row >= 0, row < items.count else { return nil } + return (items[row] as? any CodeSuggestionEntry)?.view + } + + public func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { + CodeSuggestionRowView() + } + + public func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { + // Only allow selection through keyboard navigation or single clicks + let event = NSApp.currentEvent + if event?.type == .leftMouseDragged { + return false + } + return true + } +} + +/// Used to draw a custom selection highlight for the table row +private class CodeSuggestionRowView: NSTableRowView { + override func drawSelection(in dirtyRect: NSRect) { + guard isSelected else { return } + guard let context = NSGraphicsContext.current?.cgContext else { return } + + context.saveGState() + defer { context.restoreGState() } + + // Create a rect that's inset from the edges and has proper padding + // TODO: We create a new selectionRect instead of using dirtyRect + // because there is a visual bug when holding down the arrow keys + // to select the first or last item, which draws a clipped + // rectangular highlight shape instead of the whole rectangle. + // Replace this when it gets fixed. + let selectionRect = NSRect( + x: SuggestionController.WINDOW_PADDING, + y: 0, + width: bounds.width - (SuggestionController.WINDOW_PADDING * 2), + height: bounds.height + ) + let cornerRadius: CGFloat = 5 + let path = NSBezierPath(roundedRect: selectionRect, xRadius: cornerRadius, yRadius: cornerRadius) + let selectionColor = NSColor.gray.withAlphaComponent(0.19) + + context.setFillColor(selectionColor.cgColor) + path.fill() + } +} diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift new file mode 100644 index 000000000..3c2028983 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift @@ -0,0 +1,253 @@ +// +// SuggestionController.swift +// CodeEditTextView +// +// Created by Abe Malla on 6/18/24. +// + +import AppKit + +/// Represents an item that can be displayed in the code suggestion view +public protocol CodeSuggestionEntry { + var view: NSView { get } +} + +public final class SuggestionController: NSWindowController { + + // MARK: - Properties + + public static var DEFAULT_SIZE: NSSize { + NSSize( + width: 256, // TODO: DOES MIN WIDTH DEPEND ON FONT SIZE? + height: rowsToWindowHeight(for: 1) + ) + } + + /// The items to be displayed in the window + public var items: [CodeSuggestionEntry] = [] { + didSet { onItemsUpdated() } + } + + /// Whether the suggestion window is visbile + public var isVisible: Bool { + window?.isVisible ?? false + } + + public weak var delegate: SuggestionControllerDelegate? + + // MARK: - Private Properties + + /// Height of a single row + static let ROW_HEIGHT: CGFloat = 21 + /// Maximum number of visible rows (8.5) + static let MAX_VISIBLE_ROWS: CGFloat = 8.5 + /// Padding at top and bottom of the window + static let WINDOW_PADDING: CGFloat = 5 + + let tableView = NSTableView() + let scrollView = NSScrollView() + let popover = NSPopover() + /// Tracks when the window is placed above the cursor + var isWindowAboveCursor = false + + let noItemsLabel: NSTextField = { + let label = NSTextField(labelWithString: "No Completions") + label.textColor = .secondaryLabelColor + label.alignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + label.isHidden = false + // TODO: GET FONT SIZE FROM THEME + label.font = .monospacedSystemFont(ofSize: 12, weight: .regular) + return label + }() + + /// An event monitor for keyboard events + private var localEventMonitor: Any? + /// Holds the observer for the window resign notifications + private var windowResignObserver: NSObjectProtocol? + /// Holds the observer for the cursor position update notifications + private var cursorPositionObserver: NSObjectProtocol? + + // MARK: - Initialization + + public init() { + let window = Self.makeWindow() + super.init(window: window) + configureTableView() + configureScrollView() + configureNoItemsLabel() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Opens the window as a child of another window. + public func showWindow(attachedTo parentWindow: NSWindow) { + guard let window = window else { return } + + parentWindow.addChildWindow(window, ordered: .above) + window.orderFront(nil) + + // Close on window switch observer + // Initialized outside of `setupEventMonitors` in order to grab the parent window + if let existingObserver = windowResignObserver { + NotificationCenter.default.removeObserver(existingObserver) + } + windowResignObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: parentWindow, + queue: .main + ) { [weak self] _ in + self?.close() + } + + self.show() + } + + /// Opens the window of items + func show() { + setupEventMonitors() + resetScrollPosition() + super.showWindow(nil) + } + + /// Close the window + public override func close() { + guard isVisible else { return } + removeEventMonitors() + super.close() + } + + private func onItemsUpdated() { + updateSuggestionWindowAndContents() + resetScrollPosition() + tableView.reloadData() + } + + private func setupEventMonitors() { + localEventMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.keyDown, .leftMouseDown, .rightMouseDown] + ) { [weak self] event in + guard let self = self else { return event } + + switch event.type { + case .keyDown: + return checkKeyDownEvents(event) + + case .leftMouseDown, .rightMouseDown: + // If we click outside the window, close the window + if !NSMouseInRect(NSEvent.mouseLocation, self.window!.frame, false) { + self.close() + } + return event + + default: + return event + } + } + + if let existingObserver = cursorPositionObserver { + NotificationCenter.default.removeObserver(existingObserver) + } + cursorPositionObserver = NotificationCenter.default.addObserver( + forName: TextViewController.cursorPositionUpdatedNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self = self, + let textViewController = notification.object as? TextViewController + else { return } + + guard self.isVisible else { return } + self.delegate?.onCursorMove() + } + } + + private func checkKeyDownEvents(_ event: NSEvent) -> NSEvent? { + if !self.isVisible { + return event + } + + switch event.keyCode { + case 53: // Escape + self.close() + return nil + + case 125, 126: // Down/Up Arrow + self.tableView.keyDown(with: event) + guard tableView.selectedRow >= 0 else { return event } + let selectedItem = items[tableView.selectedRow] + self.delegate?.onItemSelect(item: selectedItem) + return nil + + case 124: // Right Arrow +// handleRightArrow() + return event + + case 123: // Left Arrow + return event + + case 36, 48: // Return/Tab + guard tableView.selectedRow >= 0 else { return event } + let selectedItem = items[tableView.selectedRow] + self.delegate?.applyCompletionItem(item: selectedItem) + self.close() + return nil + + default: + return event + } + } + + private func handleRightArrow() { + guard let window = self.window, + let selectedRow = tableView.selectedRowIndexes.first, + selectedRow < items.count, + !popover.isShown else { + return + } + let rowRect = tableView.rect(ofRow: selectedRow) + let rowRectInWindow = tableView.convert(rowRect, to: nil) + let popoverPoint = NSPoint( + x: window.frame.maxX, + y: window.frame.minY + rowRectInWindow.midY + ) + popover.show( + relativeTo: NSRect(x: popoverPoint.x, y: popoverPoint.y, width: 1, height: 1), + of: window.contentView!, + preferredEdge: .maxX + ) + } + + private func resetScrollPosition() { + guard let clipView = scrollView.contentView as? NSClipView else { return } + + // Scroll to the top of the content + clipView.scroll(to: NSPoint(x: 0, y: -Self.WINDOW_PADDING)) + + // Select the first item + if !items.isEmpty { + tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) + } + } + + private func removeEventMonitors() { + if let monitor = localEventMonitor { + NSEvent.removeMonitor(monitor) + localEventMonitor = nil + } + if let observer = windowResignObserver { + NotificationCenter.default.removeObserver(observer) + windowResignObserver = nil + } + if let observer = cursorPositionObserver { + NotificationCenter.default.removeObserver(observer) + cursorPositionObserver = nil + } + } + + deinit { + removeEventMonitors() + } +} diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift new file mode 100644 index 000000000..0abf92470 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift @@ -0,0 +1,14 @@ +// +// SuggestionControllerDelegate.swift +// CodeEditSourceEditor +// +// Created by Abe Malla on 12/26/24. +// + +public protocol SuggestionControllerDelegate: AnyObject { + func applyCompletionItem(item: CodeSuggestionEntry) + func onClose() + func onCompletion() + func onCursorMove() + func onItemSelect(item: CodeSuggestionEntry) +} diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 34eb0dd42..607d2ca0b 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -110,10 +110,10 @@ extension TextViewController { } .store(in: &cancellables) - if let localEventMonitor = self.localEvenMonitor { + if let localEventMonitor = self.localEventMonitor { NSEvent.removeMonitor(localEventMonitor) } - self.localEvenMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + self.localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in guard self?.view.window?.firstResponder == self?.textView else { return event } let tabKey: UInt16 = 0x30 @@ -126,6 +126,7 @@ extension TextViewController { } } } + func handleCommand(event: NSEvent, modifierFlags: UInt) -> NSEvent? { let commandKey = NSEvent.ModifierFlags.command.rawValue diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 926c8ab10..6f35fbb34 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -28,7 +28,7 @@ public class TextViewController: NSViewController { internal var highlightLayers: [CALayer] = [] internal var systemAppearance: NSAppearance.Name? - package var localEvenMonitor: Any? + package var localEventMonitor: Any? package var isPostingCursorNotification: Bool = false /// The string contents. @@ -266,7 +266,6 @@ public class TextViewController: NSViewController { isEditable: isEditable, isSelectable: isSelectable, letterSpacing: letterSpacing, - useSystemCursor: platformGuardedSystemCursor, delegate: self ) @@ -317,10 +316,10 @@ public class TextViewController: NSViewController { textCoordinators.removeAll() NotificationCenter.default.removeObserver(self) cancellables.forEach { $0.cancel() } - if let localEvenMonitor { - NSEvent.removeMonitor(localEvenMonitor) + if let localEventMonitor { + NSEvent.removeMonitor(localEventMonitor) } - localEvenMonitor = nil + localEventMonitor = nil } } diff --git a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift index 0795c15f3..8cd51d67c 100644 --- a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift @@ -16,7 +16,7 @@ extension TreeSitterClient { public struct NodeResult { let id: TreeSitterLanguage let language: Language - let node: Node + public let node: Node } public struct QueryResult {