From 36b86c417f9b504c424214f0959085ec46c6a135 Mon Sep 17 00:00:00 2001 From: Abe M Date: Sat, 19 Oct 2024 22:31:19 -0700 Subject: [PATCH 01/24] Updates --- Package.resolved | 13 ++----------- Package.swift | 5 +++-- .../CodeEditSourceEditor/CodeEditSourceEditor.swift | 10 +++------- .../Controller/TextViewController+LoadView.swift | 5 +++-- .../Controller/TextViewController.swift | 9 ++++----- .../TextView+/TextView+TextFormation.swift | 2 +- .../CodeEditSourceEditor/Gutter/GutterView.swift | 2 +- 7 files changed, 17 insertions(+), 29 deletions(-) diff --git a/Package.resolved b/Package.resolved index 4b1c88c86..2eedaba41 100644 --- a/Package.resolved +++ b/Package.resolved @@ -9,15 +9,6 @@ "version" : "0.1.19" } }, - { - "identity" : "codeedittextview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", - "state" : { - "revision" : "2619cb945b4d6c2fc13f22ba873ba891f552b0f3", - "version" : "0.7.6" - } - }, { "identity" : "mainoffender", "kind" : "remoteSourceControl", @@ -41,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", - "version" : "1.1.2" + "revision" : "9bf03ff58ce34478e66aaee630e491823326fd06", + "version" : "1.1.3" } }, { diff --git a/Package.swift b/Package.swift index b2efb53a3..25b53df4f 100644 --- a/Package.swift +++ b/Package.swift @@ -16,8 +16,9 @@ let package = Package( dependencies: [ // A fast, efficient, text view for code. .package( - url: "https://github.com/CodeEditApp/CodeEditTextView.git", - from: "0.7.6" +// url: "https://github.com/CodeEditApp/CodeEditTextView.git", +// from: "0.7.6" + path: "../CodeEditTextView" ), // tree-sitter languages .package( diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift index 2f856a5d9..98402bde2 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift @@ -42,7 +42,6 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// character's width between characters, etc. Defaults to `1.0` /// - bracketPairHighlight: The type of highlight to use to highlight bracket pairs. /// See `BracketPairHighlight` for more information. Defaults to `nil` - /// - useSystemCursor: If true, uses the system cursor on `>=macOS 14`. /// - undoManager: The undo manager for the text view. Defaults to `nil`, which will create a new CEUndoManager /// - coordinators: Any text coordinators for the view to use. See ``TextViewCoordinator`` for more information. public init( @@ -191,6 +190,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { public typealias NSViewControllerType = TextViewController + // TODO: SET COMPLETIONPROVIDER FOR TEXTVIEW public func makeNSViewController(context: Context) -> TextViewController { let controller = TextViewController( string: "", @@ -235,6 +235,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { Coordinator(text: text, cursorPositions: cursorPositions) } + // TODO: SET COMPLETIONPROVIDER FOR TEXTVIEW public func updateNSViewController(_ controller: TextViewController, context: Context) { if !context.coordinator.isUpdateFromTextView { // Prevent infinite loop of update notifications @@ -301,10 +302,6 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { controller.letterSpacing = letterSpacing } - if controller.useSystemCursor != useSystemCursor { - controller.useSystemCursor = useSystemCursor - } - controller.bracketPairHighlight = bracketPairHighlight } @@ -325,8 +322,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { controller.indentOption == indentOption && controller.tabWidth == tabWidth && controller.letterSpacing == letterSpacing && - controller.bracketPairHighlight == bracketPairHighlight && - controller.useSystemCursor == useSystemCursor + controller.bracketPairHighlight == bracketPairHighlight } } 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 4dbf282a2..57e1de7df 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. @@ -254,7 +254,6 @@ public class TextViewController: NSViewController { isEditable: isEditable, isSelectable: isSelectable, letterSpacing: letterSpacing, - useSystemCursor: platformGuardedSystemCursor, delegate: self ) @@ -305,10 +304,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/Extensions/TextView+/TextView+TextFormation.swift b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift index 99e80effb..a9af9e2e0 100644 --- a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift +++ b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift @@ -46,7 +46,7 @@ extension TextView: TextInterface { textStorage.beginEditing() layoutManager.willReplaceCharactersInRange(range: mutation.range, with: mutation.string) - _undoManager?.registerMutation(mutation) +// _undoManager?.registerMutation(mutation) textStorage.replaceCharacters(in: mutation.range, with: mutation.string) selectionManager.didReplaceCharacters( in: mutation.range, diff --git a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift index 1cf44ea7e..6c8044a8a 100644 --- a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift @@ -7,7 +7,7 @@ import AppKit import CodeEditTextView -import CodeEditTextViewObjC +//import CodeEditTextViewObjC public protocol GutterViewDelegate: AnyObject { func gutterViewWidthDidUpdate(newWidth: CGFloat) From a1431443127588359dd7e76df357054a5a86204d Mon Sep 17 00:00:00 2001 From: Abe M Date: Tue, 17 Dec 2024 23:27:10 -0800 Subject: [PATCH 02/24] ItemBox updates --- Package.resolved | 9 --------- .../Controller/TextViewController+IndentLines.swift | 2 +- Sources/CodeEditSourceEditor/Gutter/GutterView.swift | 2 +- .../TreeSitter/TreeSitterClient+Query.swift | 2 +- 4 files changed, 3 insertions(+), 12 deletions(-) diff --git a/Package.resolved b/Package.resolved index 601a5585b..76ffd3f8e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -9,15 +9,6 @@ "version" : "0.1.20" } }, - { - "identity" : "mainoffender", - "kind" : "remoteSourceControl", - "location" : "https://github.com/mattmassicotte/MainOffender", - "state" : { - "revision" : "343cc3797618c29b48b037b4e2beea0664e75315", - "version" : "0.1.0" - } - }, { "identity" : "rearrange", "kind" : "remoteSourceControl", diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift index 8d690b76f..cad90cfa9 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift @@ -23,7 +23,7 @@ extension TextViewController { guard !cursorPositions.isEmpty else { return } textView.undoManager?.beginUndoGrouping() -for cursorPosition in self.cursorPositions.reversed() { + for cursorPosition in self.cursorPositions.reversed() { // get lineindex, i.e line-numbers+1 guard let lineIndexes = getHighlightedLines(for: cursorPosition.range) else { continue } diff --git a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift index 80fc1a714..31568d4a1 100644 --- a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift @@ -7,7 +7,7 @@ import AppKit import CodeEditTextView -//import CodeEditTextViewObjC +import CodeEditTextViewObjC public protocol GutterViewDelegate: AnyObject { func gutterViewWidthDidUpdate(newWidth: CGFloat) diff --git a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift index 0795c15f3..f7f73c3ba 100644 --- a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift @@ -12,7 +12,7 @@ import SwiftTreeSitter // Functions for querying and navigating the tree-sitter node tree. These functions should throw if not able to be // performed asynchronously as (currently) any editing tasks that would use these must be performed synchronously. -extension TreeSitterClient { +public extension TreeSitterClient { public struct NodeResult { let id: TreeSitterLanguage let language: Language From 32a7756751548a85369583605082a89b39244336 Mon Sep 17 00:00:00 2001 From: Abe M Date: Sun, 22 Dec 2024 03:40:12 -0800 Subject: [PATCH 03/24] Small update --- .../TreeSitter/TreeSitterClient+Query.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift index f7f73c3ba..0795c15f3 100644 --- a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift @@ -12,7 +12,7 @@ import SwiftTreeSitter // Functions for querying and navigating the tree-sitter node tree. These functions should throw if not able to be // performed asynchronously as (currently) any editing tasks that would use these must be performed synchronously. -public extension TreeSitterClient { +extension TreeSitterClient { public struct NodeResult { let id: TreeSitterLanguage let language: Language From 924d86fcde0786c998b22c0384057e591d9bea77 Mon Sep 17 00:00:00 2001 From: Abe M Date: Sun, 22 Dec 2024 03:42:29 -0800 Subject: [PATCH 04/24] Small update --- .../Extensions/TextView+/TextView+TextFormation.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift index a9af9e2e0..99e80effb 100644 --- a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift +++ b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift @@ -46,7 +46,7 @@ extension TextView: TextInterface { textStorage.beginEditing() layoutManager.willReplaceCharactersInRange(range: mutation.range, with: mutation.string) -// _undoManager?.registerMutation(mutation) + _undoManager?.registerMutation(mutation) textStorage.replaceCharacters(in: mutation.range, with: mutation.string) selectionManager.didReplaceCharacters( in: mutation.range, From 99472561cbf38233e30b03aed49b1ad15a621f7d Mon Sep 17 00:00:00 2001 From: Abe M Date: Thu, 26 Dec 2024 17:58:46 -0800 Subject: [PATCH 05/24] Moved code from TextView, added more functionality to delegate --- Package.swift | 5 +- .../CodeSuggestion/NoSlotScroller.swift | 16 + .../SuggestionController+Window.swift | 284 ++++++++++++++++++ .../CodeSuggestion/SuggestionController.swift | 262 ++++++++++++++++ .../TreeSitter/TreeSitterClient+Query.swift | 2 +- 5 files changed, 565 insertions(+), 4 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/CodeSuggestion/NoSlotScroller.swift create mode 100644 Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift create mode 100644 Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift diff --git a/Package.swift b/Package.swift index 69b0925ac..cf1f67303 100644 --- a/Package.swift +++ b/Package.swift @@ -16,9 +16,8 @@ let package = Package( dependencies: [ // A fast, efficient, text view for code. .package( -// url: "https://github.com/CodeEditApp/CodeEditTextView.git", -// from: "0.7.7" - path: "../CodeEditTextView" + url: "https://github.com/CodeEditApp/CodeEditTextView.git", + from: "0.7.7" ), // tree-sitter languages .package( 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..9fb794f90 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift @@ -0,0 +1,284 @@ +// +// 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) { + guard let window = self.window, + let screenFrame = window.screen?.visibleFrame else { + return + } + + let windowSize = window.frame.size + let padding: CGFloat = 22 + // TODO: PASS IN OFFSET + var newWindowOrigin = NSPoint( + x: cursorRect.origin.x - Self.WINDOW_PADDING - 13 - 16.5, + 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? { + (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 + } +} + +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..0ac956f1c --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift @@ -0,0 +1,262 @@ +// +// SuggestionController.swift +// CodeEditTextView +// +// Created by Abe Malla on 6/18/24. +// + +import AppKit +import LanguageServerProtocol + +/// 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: [CompletionItem] = [] { + 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() + } +} + +public protocol SuggestionControllerDelegate: AnyObject { + func applyCompletionItem(item: CompletionItem) + func onClose() + func onCompletion() + func onCursorMove() + func onItemSelect(item: CompletionItem) +} 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 { From afc302e6c7afd41364dafced25da248aceff1cd7 Mon Sep 17 00:00:00 2001 From: Abe M Date: Sun, 29 Dec 2024 05:10:37 -0800 Subject: [PATCH 06/24] Small updates --- Package.resolved | 9 +++++++++ .../CodeEditSourceEditor.swift | 10 +++++++--- .../SuggestionController+Window.swift | 9 +++++---- .../CodeSuggestion/SuggestionController.swift | 8 -------- .../SuggestionControllerDelegate.swift | 16 ++++++++++++++++ 5 files changed, 37 insertions(+), 15 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift diff --git a/Package.resolved b/Package.resolved index 76ffd3f8e..7a2ee4bc0 100644 --- a/Package.resolved +++ b/Package.resolved @@ -9,6 +9,15 @@ "version" : "0.1.20" } }, + { + "identity" : "codeedittextview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", + "state" : { + "revision" : "509d7b2e86460e8ec15b0dd5410cbc8e8c05940f", + "version" : "0.7.7" + } + }, { "identity" : "rearrange", "kind" : "remoteSourceControl", diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift index 72a8b2022..c5a6562b6 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift @@ -42,6 +42,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// character's width between characters, etc. Defaults to `1.0` /// - bracketPairHighlight: The type of highlight to use to highlight bracket pairs. /// See `BracketPairHighlight` for more information. Defaults to `nil` + /// - useSystemCursor: If true, uses the system cursor on `>=macOS 14`. /// - undoManager: The undo manager for the text view. Defaults to `nil`, which will create a new CEUndoManager /// - coordinators: Any text coordinators for the view to use. See ``TextViewCoordinator`` for more information. public init( @@ -190,7 +191,6 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { public typealias NSViewControllerType = TextViewController - // TODO: SET COMPLETIONPROVIDER FOR TEXTVIEW public func makeNSViewController(context: Context) -> TextViewController { let controller = TextViewController( string: "", @@ -235,7 +235,6 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { Coordinator(text: text, cursorPositions: cursorPositions) } - // TODO: SET COMPLETIONPROVIDER FOR TEXTVIEW public func updateNSViewController(_ controller: TextViewController, context: Context) { if !context.coordinator.isUpdateFromTextView { // Prevent infinite loop of update notifications @@ -302,6 +301,10 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { controller.letterSpacing = letterSpacing } + if controller.useSystemCursor != useSystemCursor { + controller.useSystemCursor = useSystemCursor + } + controller.bracketPairHighlight = bracketPairHighlight } @@ -322,7 +325,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { controller.indentOption == indentOption && controller.tabWidth == tabWidth && controller.letterSpacing == letterSpacing && - controller.bracketPairHighlight == bracketPairHighlight + controller.bracketPairHighlight == bracketPairHighlight && + controller.useSystemCursor == useSystemCursor } } diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift index 9fb794f90..40fed2bcc 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift @@ -9,7 +9,7 @@ import AppKit extension SuggestionController { /// Will constrain the window's frame to be within the visible screen - public func constrainWindowToScreenEdges(cursorRect: NSRect) { + public func constrainWindowToScreenEdges(cursorRect: NSRect, horizontalOffset: CGFloat) { guard let window = self.window, let screenFrame = window.screen?.visibleFrame else { return @@ -17,9 +17,8 @@ extension SuggestionController { let windowSize = window.frame.size let padding: CGFloat = 22 - // TODO: PASS IN OFFSET var newWindowOrigin = NSPoint( - x: cursorRect.origin.x - Self.WINDOW_PADDING - 13 - 16.5, + x: cursorRect.origin.x - Self.WINDOW_PADDING - horizontalOffset, y: cursorRect.origin.y ) @@ -237,7 +236,8 @@ extension SuggestionController: NSTableViewDataSource, NSTableViewDelegate { } public func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { - (items[row] as? any CodeSuggestionEntry)?.view + 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? { @@ -254,6 +254,7 @@ extension SuggestionController: NSTableViewDataSource, NSTableViewDelegate { } } +/// 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 } diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift index 0ac956f1c..307f96613 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift @@ -252,11 +252,3 @@ public final class SuggestionController: NSWindowController { removeEventMonitors() } } - -public protocol SuggestionControllerDelegate: AnyObject { - func applyCompletionItem(item: CompletionItem) - func onClose() - func onCompletion() - func onCursorMove() - func onItemSelect(item: CompletionItem) -} diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift new file mode 100644 index 000000000..9c842bbeb --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift @@ -0,0 +1,16 @@ +// +// SuggestionControllerDelegate.swift +// CodeEditSourceEditor +// +// Created by Abe Malla on 12/26/24. +// + +import LanguageServerProtocol + +public protocol SuggestionControllerDelegate: AnyObject { + func applyCompletionItem(item: CompletionItem) + func onClose() + func onCompletion() + func onCursorMove() + func onItemSelect(item: CompletionItem) +} From d1a46045d84eebabbffdb95f9024990e4a5f1d7b Mon Sep 17 00:00:00 2001 From: Abe M Date: Sun, 29 Dec 2024 17:10:12 -0800 Subject: [PATCH 07/24] Replaced CompletionItem type --- .../CodeSuggestion/SuggestionController.swift | 3 +-- .../CodeSuggestion/SuggestionControllerDelegate.swift | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift index 307f96613..3c2028983 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift @@ -6,7 +6,6 @@ // import AppKit -import LanguageServerProtocol /// Represents an item that can be displayed in the code suggestion view public protocol CodeSuggestionEntry { @@ -25,7 +24,7 @@ public final class SuggestionController: NSWindowController { } /// The items to be displayed in the window - public var items: [CompletionItem] = [] { + public var items: [CodeSuggestionEntry] = [] { didSet { onItemsUpdated() } } diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift index 9c842bbeb..0abf92470 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift @@ -5,12 +5,10 @@ // Created by Abe Malla on 12/26/24. // -import LanguageServerProtocol - public protocol SuggestionControllerDelegate: AnyObject { - func applyCompletionItem(item: CompletionItem) + func applyCompletionItem(item: CodeSuggestionEntry) func onClose() func onCompletion() func onCursorMove() - func onItemSelect(item: CompletionItem) + func onItemSelect(item: CodeSuggestionEntry) } From 843303e013ac29d554c1a20f47169ed2f5a216a0 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 18 Jun 2025 09:58:26 -0500 Subject: [PATCH 08/24] Fix Typo & Warnings --- .../CodeSuggestion/SuggestionController+Window.swift | 2 +- .../Controller/TextViewController+LoadView.swift | 4 ++-- .../CodeEditSourceEditor/Controller/TextViewController.swift | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift index 40fed2bcc..f7a9122eb 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift @@ -237,7 +237,7 @@ extension SuggestionController: NSTableViewDataSource, NSTableViewDelegate { 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 + return items[row].view } public func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 9b7028a9e..e9bf3f061 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -65,10 +65,10 @@ extension TextViewController { textView.updateFrameIfNeeded() - if let localEventMonitor = self.localEvenMonitor { + if let localEventMonitor = self.localEventMonitor { NSEvent.removeMonitor(localEventMonitor) } - setUpKeyBindings(eventMonitor: &self.localEvenMonitor) + setUpKeyBindings(eventMonitor: &self.localEventMonitor) updateContentInsets() } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 6395eec50..dab709b7b 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -32,7 +32,7 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty var _undoManager: CEUndoManager! var systemAppearance: NSAppearance.Name? - var localEvenMonitor: Any? + var localEventMonitor: Any? var isPostingCursorNotification: Bool = false /// The string contents. From 46a7d67566e86ed09b9cd21fdcaaebc050d8f42e Mon Sep 17 00:00:00 2001 From: Abe M Date: Wed, 23 Jul 2025 04:34:15 -0700 Subject: [PATCH 09/24] AutoCompleteCoordinator --- .../AutoCompleteCoordinatorProtocol.swift | 13 ++++++ .../CodeSuggestion/SuggestionController.swift | 42 +++++-------------- .../SuggestionControllerDelegate.swift | 2 + .../TextViewController+Lifecycle.swift | 7 ++++ .../Controller/TextViewController.swift | 3 ++ 5 files changed, 36 insertions(+), 31 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/CodeSuggestion/AutoCompleteCoordinatorProtocol.swift diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/AutoCompleteCoordinatorProtocol.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/AutoCompleteCoordinatorProtocol.swift new file mode 100644 index 000000000..e70fe1c5c --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/AutoCompleteCoordinatorProtocol.swift @@ -0,0 +1,13 @@ +// +// AutoCompleteCoordinatorProtocol.swift +// CodeEditSourceEditor +// +// Created by Abe Malla on 4/8/25. +// + +import LanguageServerProtocol + +public protocol AutoCompleteCoordinatorProtocol: TextViewCoordinator { + func fetchCompletions() async throws -> [CompletionItem] + func showAutocompleteWindow() +} diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift index 3c2028983..ee9987ade 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift @@ -46,7 +46,6 @@ public final class SuggestionController: NSWindowController { let tableView = NSTableView() let scrollView = NSScrollView() - let popover = NSPopover() /// Tracks when the window is placed above the cursor var isWindowAboveCursor = false @@ -82,9 +81,15 @@ public final class SuggestionController: NSWindowController { fatalError("init(coder:) has not been implemented") } + deinit { + removeEventMonitors() + } + /// Opens the window as a child of another window. - public func showWindow(attachedTo parentWindow: NSWindow) { - guard let window = window else { return } + public func showWindow() { + guard let window = window, + let parentWindow = NSApplication.shared.keyWindow + else { return } parentWindow.addChildWindow(window, ordered: .above) window.orderFront(nil) @@ -176,9 +181,9 @@ public final class SuggestionController: NSWindowController { 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) + let row = tableView.selectedRow + guard row >= 0, row < items.count else { return event } + self.delegate?.onItemSelect(item: items[row]) return nil case 124: // Right Arrow @@ -192,7 +197,6 @@ public final class SuggestionController: NSWindowController { guard tableView.selectedRow >= 0 else { return event } let selectedItem = items[tableView.selectedRow] self.delegate?.applyCompletionItem(item: selectedItem) - self.close() return nil default: @@ -200,26 +204,6 @@ public final class SuggestionController: NSWindowController { } } - 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 } @@ -246,8 +230,4 @@ public final class SuggestionController: NSWindowController { cursorPositionObserver = nil } } - - deinit { - removeEventMonitors() - } } diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift index 0abf92470..f97e3c3c0 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift @@ -6,6 +6,8 @@ // public protocol SuggestionControllerDelegate: AnyObject { + var currentFilterText: String { get } + func applyCompletionItem(item: CodeSuggestionEntry) func onClose() func onCompletion() diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift index bfc57c8e2..4b8304a88 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift @@ -212,6 +212,7 @@ extension TextViewController { func handleCommand(event: NSEvent, modifierFlags: UInt) -> NSEvent? { let commandKey = NSEvent.ModifierFlags.command.rawValue + let controlKey = NSEvent.ModifierFlags.control.rawValue switch (modifierFlags, event.charactersIgnoringModifiers) { case (commandKey, "/"): @@ -230,6 +231,12 @@ extension TextViewController { case (0, "\u{1b}"): // Escape key self.findViewController?.hideFindPanel() return nil + case (controlKey, " "): +// suggestionController.showWindow() + let autocompleteCoordinators = textCoordinators.map { + ($0.val as? AutoCompleteCoordinatorProtocol)?.showAutocompleteWindow() + } + return nil case (_, _): return event } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 47d6dbc77..28e2767a9 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -194,6 +194,9 @@ public class TextViewController: NSViewController { ) } + /// The `SuggestionController` lets us display the autocomplete items + public lazy var suggestionController: SuggestionController = SuggestionController() + // MARK: Init public init( From c9f1d9e62dfe0bd9da24906e2a4660d8b1f5fb4d Mon Sep 17 00:00:00 2001 From: Abe M Date: Wed, 23 Jul 2025 04:52:03 -0700 Subject: [PATCH 10/24] Remove comment --- .../Controller/TextViewController+Lifecycle.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift index 4b8304a88..33acf0f52 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift @@ -232,7 +232,6 @@ extension TextViewController { self.findViewController?.hideFindPanel() return nil case (controlKey, " "): -// suggestionController.showWindow() let autocompleteCoordinators = textCoordinators.map { ($0.val as? AutoCompleteCoordinatorProtocol)?.showAutocompleteWindow() } From a5bcf89ef2485a43361db07e26d1dd63c66357bc Mon Sep 17 00:00:00 2001 From: Abe M Date: Wed, 23 Jul 2025 04:56:49 -0700 Subject: [PATCH 11/24] Fix error --- .../CodeSuggestion/AutoCompleteCoordinatorProtocol.swift | 4 +--- .../CodeSuggestion/SuggestionController.swift | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/AutoCompleteCoordinatorProtocol.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/AutoCompleteCoordinatorProtocol.swift index e70fe1c5c..4956c7083 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/AutoCompleteCoordinatorProtocol.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/AutoCompleteCoordinatorProtocol.swift @@ -5,9 +5,7 @@ // Created by Abe Malla on 4/8/25. // -import LanguageServerProtocol - public protocol AutoCompleteCoordinatorProtocol: TextViewCoordinator { - func fetchCompletions() async throws -> [CompletionItem] + func fetchCompletions() async throws -> [CodeSuggestionEntry] func showAutocompleteWindow() } diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift index ee9987ade..14b405447 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift @@ -187,7 +187,6 @@ public final class SuggestionController: NSWindowController { return nil case 124: // Right Arrow -// handleRightArrow() return event case 123: // Left Arrow From fefc805fb9ece6f27b9ddf5338669ceb10378940 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 23 Jul 2025 10:22:45 -0500 Subject: [PATCH 12/24] Refactor Suggestion Window - Creates a `SuggestionViewController` for managing the view contents of the window. - Renames `SuggestionControllerDelegate` to `CodeSuggestionDelegate` - Moves `CodeSuggestionEntry` to its own file. - Removes a few magic numbers - Removes the `horizontalOffset` parameter when moving the window. We'll rely on the suggestion delegate to tell us where to place the window's top-left corner. --- .../CodeSuggestionDelegate.swift | 38 ++++ .../CodeSuggestion/CodeSuggestionEntry.swift | 13 ++ .../CodeSuggestionRowView.swift | 49 +++++ .../SuggestionController+Window.swift | 163 +-------------- .../CodeSuggestion/SuggestionController.swift | 152 +++++--------- .../SuggestionControllerDelegate.swift | 16 -- .../SuggestionViewController.swift | 189 ++++++++++++++++++ .../CodeSuggestion/SuggestionViewModel.swift | 107 ++++++++++ 8 files changed, 457 insertions(+), 270 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/CodeSuggestion/CodeSuggestionDelegate.swift create mode 100644 Sources/CodeEditSourceEditor/CodeSuggestion/CodeSuggestionEntry.swift create mode 100644 Sources/CodeEditSourceEditor/CodeSuggestion/CodeSuggestionRowView.swift delete mode 100644 Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift create mode 100644 Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionViewController.swift create mode 100644 Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionViewModel.swift diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/CodeSuggestionDelegate.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/CodeSuggestionDelegate.swift new file mode 100644 index 000000000..8a3b3b29a --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/CodeSuggestionDelegate.swift @@ -0,0 +1,38 @@ +// +// CodeSuggestionDelegate.swift +// CodeEditSourceEditor +// +// Created by Abe Malla on 12/26/24. +// + +public protocol CodeSuggestionDelegate: AnyObject { + func completionTriggerCharacters() -> Set + + func completionSuggestionsRequested( + textView: TextViewController, + cursorPosition: CursorPosition + ) async -> (windowPosition: CursorPosition, items: [CodeSuggestionEntry])? + + // This can't be async, we need it to be snappy. At most, it should just be filtering completion items + func completionOnCursorMove( + textView: TextViewController, + cursorPosition: CursorPosition + ) -> [CodeSuggestionEntry]? + + // Optional + func completionWindowDidClose() + + func completionWindowApplyCompletion( + item: CodeSuggestionEntry, + textView: TextViewController, + cursorPosition: CursorPosition + ) + // Optional + func completionWindowDidSelect(item: CodeSuggestionEntry) +} + +public extension CodeSuggestionDelegate { + func completionTriggerCharacters() -> Set { [] } + func completionWindowDidClose() { } + func completionWindowDidSelect(item: CodeSuggestionEntry) { } +} diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/CodeSuggestionEntry.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/CodeSuggestionEntry.swift new file mode 100644 index 000000000..007da7b93 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/CodeSuggestionEntry.swift @@ -0,0 +1,13 @@ +// +// CodeSuggestionEntry.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 7/22/25. +// + +import AppKit + +/// Represents an item that can be displayed in the code suggestion view +public protocol CodeSuggestionEntry { + var view: NSView { get } +} diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/CodeSuggestionRowView.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/CodeSuggestionRowView.swift new file mode 100644 index 000000000..0ab5468e8 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/CodeSuggestionRowView.swift @@ -0,0 +1,49 @@ +// +// CodeSuggestionRowView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 7/22/25. +// + +import AppKit + +/// Used to draw a custom selection highlight for the table row +final class CodeSuggestionRowView: NSTableRowView { + var getSelectionColor: (() -> NSColor)? + + init(getSelectionColor: (() -> NSColor)? = nil) { + self.getSelectionColor = getSelectionColor + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + 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 = getSelectionColor?() ?? NSColor.controlBackgroundColor + + context.setFillColor(selectionColor.cgColor) + path.fill() + } +} diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift index f7a9122eb..5838fffe5 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift @@ -9,7 +9,7 @@ import AppKit extension SuggestionController { /// Will constrain the window's frame to be within the visible screen - public func constrainWindowToScreenEdges(cursorRect: NSRect, horizontalOffset: CGFloat) { + public func constrainWindowToScreenEdges(cursorRect: NSRect) { guard let window = self.window, let screenFrame = window.screen?.visibleFrame else { return @@ -18,7 +18,7 @@ extension SuggestionController { let windowSize = window.frame.size let padding: CGFloat = 22 var newWindowOrigin = NSPoint( - x: cursorRect.origin.x - Self.WINDOW_PADDING - horizontalOffset, + x: cursorRect.origin.x - Self.WINDOW_PADDING, y: cursorRect.origin.y ) @@ -64,17 +64,11 @@ extension SuggestionController { static func makeWindow() -> NSWindow { let window = NSWindow( contentRect: NSRect(origin: .zero, size: self.DEFAULT_SIZE), - styleMask: [.resizable, .fullSizeContentView, .nonactivatingPanel], + styleMask: [.resizable, .fullSizeContentView, .nonactivatingPanel, .utilityWindow], backing: .buffered, defer: false ) - configureWindow(window) - configureWindowContent(window) - return window - } - - static func configureWindow(_ window: NSWindow) { window.titleVisibility = .hidden window.titlebarAppearsTransparent = true window.isExcludedFromWindowsMenu = true @@ -86,87 +80,8 @@ extension SuggestionController { 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) - ]) + return window } /// Updates the item box window's height based on the number of items. @@ -176,12 +91,9 @@ extension SuggestionController { 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 ? + let numberOfVisibleRows = min(CGFloat(model.items.count), Self.MAX_VISIBLE_ROWS) + let newHeight = model.items.count == 0 ? Self.rowsToWindowHeight(for: 1) : // Height for 1 row when empty Self.rowsToWindowHeight(for: numberOfVisibleRows) @@ -206,15 +118,6 @@ extension SuggestionController { 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) @@ -229,57 +132,3 @@ extension SuggestionController { 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].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 index 14b405447..4d7c6e970 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift @@ -6,34 +6,28 @@ // import AppKit - -/// Represents an item that can be displayed in the code suggestion view -public protocol CodeSuggestionEntry { - var view: NSView { get } -} +import CodeEditTextView +import Combine public final class SuggestionController: NSWindowController { + static var shared: SuggestionController = SuggestionController() // MARK: - Properties - public static var DEFAULT_SIZE: NSSize { + 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 { + /// Whether the suggestion window is visibile + var isVisible: Bool { window?.isVisible ?? false } - public weak var delegate: SuggestionControllerDelegate? + var itemObserver: AnyCancellable? + var model: SuggestionViewModel = SuggestionViewModel() // MARK: - Private Properties @@ -44,45 +38,52 @@ public final class SuggestionController: NSWindowController { /// Padding at top and bottom of the window static let WINDOW_PADDING: CGFloat = 5 - let tableView = NSTableView() - let scrollView = NSScrollView() /// 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() + + let controller = SuggestionViewController() + controller.model = model + window.contentViewController = controller + super.init(window: window) - configureTableView() - configureScrollView() - configureNoItemsLabel() + + if window.isVisible { + window.close() + } + + itemObserver = model.$items.receive(on: DispatchQueue.main).sink { [weak self] _ in + self?.onItemsUpdated() + } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - deinit { - removeEventMonitors() + func showCompletions( + textView: TextViewController, + delegate: CodeSuggestionDelegate, + cursorPosition: CursorPosition + ) { + model.showCompletions( + textView: textView, + delegate: delegate, + cursorPosition: cursorPosition + ) { parentWindow, cursorRect in + self.showWindow(attachedTo: parentWindow) + self.constrainWindowToScreenEdges(cursorRect: cursorRect) + (self.contentViewController as? SuggestionViewController)?.styleView(using: textView) + } } /// Opens the window as a child of another window. @@ -92,7 +93,6 @@ public final class SuggestionController: NSWindowController { 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 @@ -107,66 +107,36 @@ public final class SuggestionController: NSWindowController { self?.close() } - self.show() - } - - /// Opens the window of items - func show() { setupEventMonitors() - resetScrollPosition() super.showWindow(nil) + window.orderFront(nil) + window.contentViewController?.viewWillAppear() } /// Close the window public override func close() { - guard isVisible else { return } + model.willClose() removeEventMonitors() super.close() } private func onItemsUpdated() { updateSuggestionWindowAndContents() - resetScrollPosition() - tableView.reloadData() } private func setupEventMonitors() { localEventMonitor = NSEvent.addLocalMonitorForEvents( - matching: [.keyDown, .leftMouseDown, .rightMouseDown] + matching: [.keyDown] ) { [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? { @@ -180,22 +150,11 @@ public final class SuggestionController: NSWindowController { return nil case 125, 126: // Down/Up Arrow - self.tableView.keyDown(with: event) - let row = tableView.selectedRow - guard row >= 0, row < items.count else { return event } - self.delegate?.onItemSelect(item: items[row]) + (contentViewController as? SuggestionViewController)?.tableView?.keyDown(with: event) return nil - case 124: // Right Arrow - 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) + (contentViewController as? SuggestionViewController)?.applySelectedItem() return nil default: @@ -203,18 +162,6 @@ public final class SuggestionController: NSWindowController { } } - 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) @@ -224,9 +171,20 @@ public final class SuggestionController: NSWindowController { NotificationCenter.default.removeObserver(observer) windowResignObserver = nil } - if let observer = cursorPositionObserver { - NotificationCenter.default.removeObserver(observer) - cursorPositionObserver = nil + } + + func cursorsUpdated( + textView: TextViewController, + delegate: CodeSuggestionDelegate, + position: CursorPosition, + presentIfNot: Bool = false + ) { + model.cursorsUpdated(textView: textView, delegate: delegate, position: position) { + close() + + if presentIfNot { + self.showCompletions(textView: textView, delegate: delegate, cursorPosition: position) + } } } } diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift deleted file mode 100644 index f97e3c3c0..000000000 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// SuggestionControllerDelegate.swift -// CodeEditSourceEditor -// -// Created by Abe Malla on 12/26/24. -// - -public protocol SuggestionControllerDelegate: AnyObject { - var currentFilterText: String { get } - - func applyCompletionItem(item: CodeSuggestionEntry) - func onClose() - func onCompletion() - func onCursorMove() - func onItemSelect(item: CodeSuggestionEntry) -} diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionViewController.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionViewController.swift new file mode 100644 index 000000000..7089b04c5 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionViewController.swift @@ -0,0 +1,189 @@ +// +// SuggestionViewController.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 7/22/25. +// + +import AppKit +import Combine + +class SuggestionViewController: NSViewController { + var tableView: NSTableView! + var scrollView: NSScrollView! + var tintView: NSView! + var noItemsLabel: NSTextField! + + var itemObserver: AnyCancellable? + weak var model: SuggestionViewModel? { + didSet { + itemObserver?.cancel() + itemObserver = model?.$items.receive(on: DispatchQueue.main).sink { [weak self] _ in + self?.onItemsUpdated() + } + } + } + + override func loadView() { + super.loadView() + view.wantsLayer = true + view.layer?.cornerRadius = 8.5 + view.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor + + tintView = NSView() + tintView.translatesAutoresizingMaskIntoConstraints = false + tintView.wantsLayer = true + tintView.layer?.cornerRadius = 8.5 + view.addSubview(tintView) + + tableView = NSTableView() + configureTableView() + scrollView = NSScrollView() + configureScrollView() + + noItemsLabel = NSTextField(labelWithString: "No Completions") + noItemsLabel.textColor = .secondaryLabelColor + noItemsLabel.alignment = .center + noItemsLabel.translatesAutoresizingMaskIntoConstraints = false + noItemsLabel.isHidden = false + // TODO: GET FONT SIZE FROM THEME + noItemsLabel.font = .monospacedSystemFont(ofSize: 12, weight: .regular) + + tintView.addSubview(noItemsLabel) + tintView.addSubview(scrollView) + + NSLayoutConstraint.activate([ + tintView.topAnchor.constraint(equalTo: view.topAnchor), + tintView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + tintView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tintView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + + noItemsLabel.centerXAnchor.constraint(equalTo: tintView.centerXAnchor), + noItemsLabel.centerYAnchor.constraint(equalTo: tintView.centerYAnchor), + scrollView.topAnchor.constraint(equalTo: tintView.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: tintView.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: tintView.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: tintView.bottomAnchor) + ]) + } + + override func viewWillAppear() { + super.viewWillAppear() + resetScrollPosition() + tableView.reloadData() + } + + func styleView(using controller: TextViewController) { + switch controller.systemAppearance { + case .aqua: + tintView.layer?.backgroundColor = controller.theme.background.withAlphaComponent(0.3).cgColor + case .darkAqua: + tintView.layer?.backgroundColor = controller.theme.background.cgColor + default: + return + } + } + + 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) + } + + 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: SuggestionController.WINDOW_PADDING, + left: 0, + bottom: SuggestionController.WINDOW_PADDING, + right: 0 + ) + } + + func onItemsUpdated() { + resetScrollPosition() + if let model { + noItemsLabel.isHidden = !model.items.isEmpty + scrollView.isHidden = model.items.isEmpty + } + tableView.reloadData() + } + + @objc private func tableViewClicked(_ sender: Any?) { + if NSApp.currentEvent?.clickCount == 2 { + applySelectedItem() + } + } + + private func resetScrollPosition() { + let clipView = scrollView.contentView + + // Scroll to the top of the content + clipView.scroll(to: NSPoint(x: 0, y: -SuggestionController.WINDOW_PADDING)) + + // Select the first item + if !(model?.items.isEmpty ?? true) { + tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) + } + } + + func applySelectedItem() { + let row = tableView.selectedRow + guard row >= 0, row < model?.items.count ?? 0 else { + return + } + if let model { + model.applySelectedItem(item: model.items[tableView.selectedRow], window: view.window) + } + } +} + +extension SuggestionViewController: NSTableViewDataSource, NSTableViewDelegate { + public func numberOfRows(in tableView: NSTableView) -> Int { + model?.items.count ?? 0 + } + + public func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + guard row >= 0, row < model?.items.count ?? 0 else { return nil } + return model?.items[row].view + } + + public func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { + CodeSuggestionRowView { [weak self] in + self?.model?.activeTextView?.theme.background ?? NSColor.controlBackgroundColor + } + } + + public func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { + // Only allow selection through keyboard navigation or single clicks + NSApp.currentEvent?.type != .leftMouseDragged + } + + public func tableViewSelectionDidChange(_ notification: Notification) { + guard tableView.selectedRow >= 0 else { return } + if let model { + model.didSelect(item: model.items[tableView.selectedRow]) + } + } +} diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionViewModel.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionViewModel.swift new file mode 100644 index 000000000..840d6c4f9 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionViewModel.swift @@ -0,0 +1,107 @@ +// +// SuggestionViewModel.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 7/22/25. +// + +import AppKit + +final class SuggestionViewModel: ObservableObject { + /// The items to be displayed in the window + @Published var items: [CodeSuggestionEntry] = [] + var itemsRequestTask: Task? + weak var activeTextView: TextViewController? + + var delegate: CodeSuggestionDelegate? { + activeTextView?.completionDelegate + } + + func showCompletions( + textView: TextViewController, + delegate: CodeSuggestionDelegate, + cursorPosition: CursorPosition, + showWindowOnParent: @escaping @MainActor (NSWindow, NSRect) -> Void + ) { + self.activeTextView = nil + itemsRequestTask?.cancel() + + guard let targetParentWindow = textView.view.window else { return } + + self.activeTextView = textView + itemsRequestTask = Task { + do { + guard let completionItems = await delegate.completionSuggestionsRequested( + textView: textView, + cursorPosition: cursorPosition + ) else { + return + } + + try Task.checkCancellation() + try await MainActor.run { + try Task.checkCancellation() + + guard let cursorPosition = textView.resolveCursorPosition(completionItems.windowPosition), + let cursorRect = textView.textView.layoutManager.rectForOffset( + cursorPosition.range.location + ), + let cursorRect = textView.view.window?.convertToScreen( + textView.textView.convert(cursorRect, to: nil) + ) else { + return + } + + self.items = completionItems.items + showWindowOnParent(targetParentWindow, cursorRect) + } + } catch { + return + } + } + } + + func cursorsUpdated( + textView: TextViewController, + delegate: CodeSuggestionDelegate, + position: CursorPosition, + close: () -> Void + ) { + if activeTextView !== textView { + close() + return + } + + guard let newItems = delegate.completionOnCursorMove( + textView: textView, + cursorPosition: position + ) else { + close() + return + } + + items = newItems + } + + func didSelect(item: CodeSuggestionEntry) { + delegate?.completionWindowDidSelect(item: item) + } + + func applySelectedItem(item: CodeSuggestionEntry, window: NSWindow?) { + guard let activeTextView, + let cursorPosition = activeTextView.cursorPositions.first else { + return + } + self.delegate?.completionWindowApplyCompletion( + item: item, + textView: activeTextView, + cursorPosition: cursorPosition + ) + window?.close() + } + + func willClose() { + items.removeAll() + activeTextView = nil + } +} From f1df981ed217171f24c07911bec45fbcf36bff5b Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 23 Jul 2025 10:23:39 -0500 Subject: [PATCH 13/24] Resolve Cursors Method, Show Completions On CMD --- .../TextViewController+Cursor.swift | 33 +++++++++++++++++-- .../TextViewController+Lifecycle.swift | 25 ++++++++++---- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift index 04af69ac7..f077f6b95 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift @@ -47,8 +47,8 @@ extension TextViewController { continue } let column = (selectedRange.range.location - linePosition.range.location) + 1 - let row = linePosition.index + 1 - positions.append(CursorPosition(range: selectedRange.range, line: row, column: column)) + let line = linePosition.index + 1 + positions.append(CursorPosition(range: selectedRange.range, line: line, column: column)) } isPostingCursorNotification = true @@ -58,5 +58,34 @@ extension TextViewController { coordinator.textViewDidChangeSelection(controller: self, newPositions: cursorPositions) } isPostingCursorNotification = false + + if let completionDelegate = completionDelegate, let position = cursorPositions.first { + SuggestionController.shared.cursorsUpdated(textView: self, delegate: completionDelegate, position: position) + } + } + + /// Fills out all properties on the given cursor position if it's missing either the range or line/column + /// information. + func resolveCursorPosition(_ position: CursorPosition) -> CursorPosition? { + var range = position.range + if range == .notFound { + guard position.line > 0, position.column > 0, + let linePosition = textView.layoutManager.textLineForIndex(position.line - 1) else { + return nil + } + range = NSRange(location: linePosition.range.location + position.column, length: 0) + } + + var line = position.line + var column = position.column + if position.line <= 0 || position.column <= 0 { + guard range != .notFound, let linePosition = textView.layoutManager.textLineForOffset(range.location) else { + return nil + } + column = (range.location - linePosition.range.location) + 1 + line = linePosition.index + 1 + } + + return CursorPosition(range: range, line: line, column: column) } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift index 33acf0f52..be30cf26c 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift @@ -229,13 +229,14 @@ extension TextViewController { self.findViewController?.showFindPanel() return nil case (0, "\u{1b}"): // Escape key - self.findViewController?.hideFindPanel() - return nil - case (controlKey, " "): - let autocompleteCoordinators = textCoordinators.map { - ($0.val as? AutoCompleteCoordinatorProtocol)?.showAutocompleteWindow() + if findViewController?.viewModel.isShowingFindPanel == true { + self.findViewController?.hideFindPanel() + return nil } - return nil + // Attempt to show completions otherwise + return handleShowCompletions(event) + case (controlKey, " "): + return handleShowCompletions(event) case (_, _): return event } @@ -258,4 +259,16 @@ extension TextViewController { } return nil } + + private func handleShowCompletions(_ event: NSEvent) -> NSEvent? { + if let completionDelegate = self.completionDelegate, let cursorPosition = cursorPositions.first { + SuggestionController.shared.showCompletions( + textView: self, + delegate: completionDelegate, + cursorPosition: cursorPosition + ) + return nil + } + return event + } } From af114f9014863662be4d368d2b0ea8a8990a5ba9 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 23 Jul 2025 10:23:56 -0500 Subject: [PATCH 14/24] Add `codeSuggestionTriggerCharacters` --- .../TextViewController+TextFormation.swift | 21 ++++++++++++ .../Controller/TextViewController.swift | 2 ++ .../Filters/CodeSuggestionTriggerFilter.swift | 32 +++++++++++++++++++ ...ourceEditorConfiguration+Peripherals.swift | 10 +++++- 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 Sources/CodeEditSourceEditor/Filters/CodeSuggestionTriggerFilter.swift diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift index b98ad44f4..1338a1a33 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift @@ -24,6 +24,7 @@ extension TextViewController { setUpNewlineTabFilters(indentOption: configuration.behavior.indentOption) setUpDeletePairFilters(pairs: BracketPairs.allValues) setUpDeleteWhitespaceFilter(indentOption: configuration.behavior.indentOption) + setUpSuggestionsFilter() } /// Returns a `TextualIndenter` based on available language configuration. @@ -120,4 +121,24 @@ extension TextViewController { return true } + + func setUpSuggestionsFilter() { + textFilters.append( + CodeSuggestionTriggerFilter( + triggerCharacters: configuration.peripherals.codeSuggestionTriggerCharacters, + didTrigger: { [weak self] in + guard let self else { return } + if let completionDelegate = self.completionDelegate, + let position = self.cursorPositions.first { + SuggestionController.shared.cursorsUpdated( + textView: self, + delegate: completionDelegate, + position: position, + presentIfNot: true + ) + } + } + ) + ) + } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 28e2767a9..87b05d9df 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -85,6 +85,8 @@ public class TextViewController: NSViewController { /// The provided highlight provider. public var highlightProviders: [HighlightProviding] + public weak var completionDelegate: CodeSuggestionDelegate? + // MARK: - Config Helpers /// The font to use in the `textView` diff --git a/Sources/CodeEditSourceEditor/Filters/CodeSuggestionTriggerFilter.swift b/Sources/CodeEditSourceEditor/Filters/CodeSuggestionTriggerFilter.swift new file mode 100644 index 000000000..c66a23bdd --- /dev/null +++ b/Sources/CodeEditSourceEditor/Filters/CodeSuggestionTriggerFilter.swift @@ -0,0 +1,32 @@ +// +// CodeSuggestionTriggerFilter.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 7/22/25. +// + +import Foundation +import TextFormation +import TextStory + +struct CodeSuggestionTriggerFilter: Filter { + let triggerCharacters: Set + let didTrigger: () -> Void + + func processMutation( + _ mutation: TextMutation, + in interface: TextInterface, + with providers: WhitespaceProviders + ) -> FilterAction { + guard mutation.delta >= 0, + let lastChar = mutation.string.last else { + return .none + } + + if triggerCharacters.contains(String(lastChar)) || lastChar.isNumber || lastChar.isLetter { + didTrigger() + } + + return .none + } +} diff --git a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift index b77cc719f..6df9a4cf7 100644 --- a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift +++ b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift @@ -28,13 +28,16 @@ extension SourceEditorConfiguration { /// non-standard quote character: `“ (0x201C)`. public var warningCharacters: Set + public var codeSuggestionTriggerCharacters: Set + public init( showGutter: Bool = true, showMinimap: Bool = true, showReformattingGuide: Bool = false, showFoldingRibbon: Bool = true, invisibleCharactersConfiguration: InvisibleCharactersConfiguration = .empty, - warningCharacters: Set = [] + warningCharacters: Set = [], + codeSuggestionTriggerCharacters: Set = [] ) { self.showGutter = showGutter self.showMinimap = showMinimap @@ -42,6 +45,7 @@ extension SourceEditorConfiguration { self.showFoldingRibbon = showFoldingRibbon self.invisibleCharactersConfiguration = invisibleCharactersConfiguration self.warningCharacters = warningCharacters + self.codeSuggestionTriggerCharacters = codeSuggestionTriggerCharacters } @MainActor @@ -79,6 +83,10 @@ extension SourceEditorConfiguration { controller.updateContentInsets() controller.updateTextInsets() } + + if oldConfig?.codeSuggestionTriggerCharacters != codeSuggestionTriggerCharacters { + controller.setUpTextFormation() + } } } } From 933c7a24d5e99ea3b415382b4f9fda0be6f94fea Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 23 Jul 2025 10:24:12 -0500 Subject: [PATCH 15/24] Add Mock Completion Delegate To Example --- .../project.pbxproj | 4 ++ .../xcshareddata/swiftpm/Package.resolved | 9 +++ .../Views/ContentView.swift | 6 +- .../Views/MockCompletionDelegate.swift | 62 +++++++++++++++++++ .../Views/StatusBar.swift | 6 +- .../SourceEditor/SourceEditor.swift | 13 +++- 6 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj index 94ac1e836..32f59107d 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 6C1365462B8A7F2D004A1D18 /* LanguagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1365452B8A7F2D004A1D18 /* LanguagePicker.swift */; }; 6C1365482B8A7FBF004A1D18 /* EditorTheme+Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1365472B8A7FBF004A1D18 /* EditorTheme+Default.swift */; }; 6C13654D2B8A821E004A1D18 /* NSColor+Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C13654C2B8A821E004A1D18 /* NSColor+Hex.swift */; }; + 6C8B564C2E3018CC00DC3F29 /* MockCompletionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8B564B2E3018CC00DC3F29 /* MockCompletionDelegate.swift */; }; 6CF31D4E2DB6A252006A77FD /* StatusBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CF31D4D2DB6A252006A77FD /* StatusBar.swift */; }; /* End PBXBuildFile section */ @@ -38,6 +39,7 @@ 6C1365452B8A7F2D004A1D18 /* LanguagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguagePicker.swift; sourceTree = ""; }; 6C1365472B8A7FBF004A1D18 /* EditorTheme+Default.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EditorTheme+Default.swift"; sourceTree = ""; }; 6C13654C2B8A821E004A1D18 /* NSColor+Hex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSColor+Hex.swift"; sourceTree = ""; }; + 6C8B564B2E3018CC00DC3F29 /* MockCompletionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCompletionDelegate.swift; sourceTree = ""; }; 6CF31D4D2DB6A252006A77FD /* StatusBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBar.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -116,6 +118,7 @@ 6C13654A2B8A7FD2004A1D18 /* Views */ = { isa = PBXGroup; children = ( + 6C8B564B2E3018CC00DC3F29 /* MockCompletionDelegate.swift */, 6C1365312B8A7B94004A1D18 /* ContentView.swift */, 6CF31D4D2DB6A252006A77FD /* StatusBar.swift */, 6C1365452B8A7F2D004A1D18 /* LanguagePicker.swift */, @@ -215,6 +218,7 @@ 6CF31D4E2DB6A252006A77FD /* StatusBar.swift in Sources */, 6C13652E2B8A7B94004A1D18 /* CodeEditSourceEditorExampleApp.swift in Sources */, 6C1365442B8A7EED004A1D18 /* String+Lines.swift in Sources */, + 6C8B564C2E3018CC00DC3F29 /* MockCompletionDelegate.swift in Sources */, 1CB30C3A2DAA1C28008058A7 /* IndentPicker.swift in Sources */, 6C1365322B8A7B94004A1D18 /* ContentView.swift in Sources */, 6C1365462B8A7F2D004A1D18 /* LanguagePicker.swift in Sources */, diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c511a9f74..e50cc92d7 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -18,6 +18,15 @@ "version" : "0.2.3" } }, + { + "identity" : "codeedittextview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", + "state" : { + "revision" : "fbb038caa8a2779153a94f6e01caa5016ffb973d", + "version" : "0.11.7" + } + }, { "identity" : "rearrange", "kind" : "remoteSourceControl", diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index 5984ab0ea..54dae18ee 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -22,6 +22,7 @@ struct ContentView: View { @State private var editorState = SourceEditorState( cursorPositions: [CursorPosition(line: 1, column: 1)] ) + @StateObject private var suggestions: MockCompletionDelegate = MockCompletionDelegate() @State private var font: NSFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium) @AppStorage("wrapLines") private var wrapLines: Bool = true @@ -71,7 +72,8 @@ struct ContentView: View { warningCharacters: warningCharacters ) ), - state: $editorState + state: $editorState, + completionDelegate: suggestions ) .overlay(alignment: .bottom) { StatusBar( @@ -88,7 +90,7 @@ struct ContentView: View { indentOption: $indentOption, reformatAtColumn: $reformatAtColumn, showReformattingGuide: $showReformattingGuide, - showFoldingRibbon: $showFoldingRibbon + showFoldingRibbon: $showFoldingRibbon, invisibles: $invisibleCharactersConfig, warningCharacters: $warningCharacters ) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift new file mode 100644 index 000000000..285f30cd3 --- /dev/null +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift @@ -0,0 +1,62 @@ +// +// MockCompletionDelegate.swift +// CodeEditSourceEditorExample +// +// Created by Khan Winter on 7/22/25. +// + +import AppKit +import CodeEditSourceEditor +import CodeEditTextView + +class MockCompletionDelegate: CodeSuggestionDelegate, ObservableObject { + class Suggestion: CodeSuggestionEntry { + let text: String + var view: NSView { + let view = NSTextField(string: text) + view.isEditable = false + view.isSelectable = false + view.isBezeled = false + view.isBordered = false + view.backgroundColor = .clear + view.textColor = .black + return view + } + + init(text: String) { + self.text = text + } + } + + func completionSuggestionsRequested( + textView: TextViewController, + cursorPosition: CursorPosition + ) async -> (windowPosition: CursorPosition, items: [CodeSuggestionEntry])? { + try? await Task.sleep(for: .seconds(0.2)) + return (cursorPosition, [Suggestion(text: "Hello"), Suggestion(text: "World")]) + } + + func completionOnCursorMove( + textView: TextViewController, + cursorPosition: CursorPosition + ) -> [CodeSuggestionEntry]? { + if Bool.random() { + [Suggestion(text: "Another one")] + } else { + nil + } + } + + func completionWindowApplyCompletion( + item: CodeSuggestionEntry, + textView: TextViewController, + cursorPosition: CursorPosition + ) { + guard let suggestion = item as? Suggestion else { + return + } + textView.textView.undoManager?.beginUndoGrouping() + textView.textView.insertText(suggestion.text) + textView.textView.undoManager?.endUndoGrouping() + } +} diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift index 597dff508..a003df36b 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift @@ -104,7 +104,7 @@ struct StatusBar: View { } } scrollPosition - Text(getLabel(state.cursorPositions)) + Text(getLabel(state.cursorPositions ?? [])) } .foregroundStyle(.secondary) @@ -118,9 +118,9 @@ struct StatusBar: View { .foregroundStyle(.secondary) Button { - state.findPanelVisible.toggle() + state.findPanelVisible?.toggle() } label: { - Text(state.findPanelVisible ? "Hide" : "Show") + Text(" Find") + Text((state.findPanelVisible ?? false) ? "Hide" : "Show") + Text(" Find") } .buttonStyle(.borderless) .foregroundStyle(.secondary) diff --git a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift index efd1fcd8d..342fb1fc8 100644 --- a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift +++ b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift @@ -35,7 +35,8 @@ public struct SourceEditor: NSViewControllerRepresentable { state: Binding, highlightProviders: [any HighlightProviding]? = nil, undoManager: CEUndoManager? = nil, - coordinators: [any TextViewCoordinator] = [] + coordinators: [any TextViewCoordinator] = [], + completionDelegate: CodeSuggestionDelegate? = nil ) { self.text = .binding(text) self.language = language @@ -44,6 +45,7 @@ public struct SourceEditor: NSViewControllerRepresentable { self.highlightProviders = highlightProviders self.undoManager = undoManager self.coordinators = coordinators + self.completionDelegate = completionDelegate } /// Initializes a new source editor @@ -64,7 +66,8 @@ public struct SourceEditor: NSViewControllerRepresentable { state: Binding, highlightProviders: [any HighlightProviding]? = nil, undoManager: CEUndoManager? = nil, - coordinators: [any TextViewCoordinator] = [] + coordinators: [any TextViewCoordinator] = [], + completionDelegate: CodeSuggestionDelegate? = nil ) { self.text = .storage(text) self.language = language @@ -73,6 +76,7 @@ public struct SourceEditor: NSViewControllerRepresentable { self.highlightProviders = highlightProviders self.undoManager = undoManager self.coordinators = coordinators + self.completionDelegate = completionDelegate } var text: TextAPI @@ -82,6 +86,7 @@ public struct SourceEditor: NSViewControllerRepresentable { var highlightProviders: [any HighlightProviding]? var undoManager: CEUndoManager? var coordinators: [any TextViewCoordinator] + weak var completionDelegate: CodeSuggestionDelegate? public typealias NSViewControllerType = TextViewController @@ -108,6 +113,8 @@ public struct SourceEditor: NSViewControllerRepresentable { controller.setCursorPositions(state.cursorPositions ?? []) } + controller.completionDelegate = completionDelegate + context.coordinator.setController(controller) return controller } @@ -117,6 +124,8 @@ public struct SourceEditor: NSViewControllerRepresentable { } public func updateNSViewController(_ controller: TextViewController, context: Context) { + controller.completionDelegate = completionDelegate + context.coordinator.updateHighlightProviders(highlightProviders) // Prevent infinite loop of update notifications From af0059ec27b4ee92ee098df66e9911f303191dd1 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 23 Jul 2025 10:34:08 -0500 Subject: [PATCH 16/24] Remove Unused Variables --- .../Views/MockCompletionDelegate.swift | 2 +- .../CodeSuggestion/CodeSuggestionRowView.swift | 4 ++-- .../CodeSuggestion/SuggestionController.swift | 7 ++----- .../Controller/TextViewController.swift | 3 --- 4 files changed, 5 insertions(+), 11 deletions(-) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift index 285f30cd3..2e4db0efc 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift @@ -35,7 +35,7 @@ class MockCompletionDelegate: CodeSuggestionDelegate, ObservableObject { try? await Task.sleep(for: .seconds(0.2)) return (cursorPosition, [Suggestion(text: "Hello"), Suggestion(text: "World")]) } - + func completionOnCursorMove( textView: TextViewController, cursorPosition: CursorPosition diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/CodeSuggestionRowView.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/CodeSuggestionRowView.swift index 0ab5468e8..6e7d7e19b 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/CodeSuggestionRowView.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/CodeSuggestionRowView.swift @@ -15,11 +15,11 @@ final class CodeSuggestionRowView: NSTableRowView { self.getSelectionColor = getSelectionColor super.init(frame: .zero) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func drawSelection(in dirtyRect: NSRect) { guard isSelected else { return } guard let context = NSGraphicsContext.current?.cgContext else { return } diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift index 4d7c6e970..e617cc5d6 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift @@ -87,11 +87,8 @@ public final class SuggestionController: NSWindowController { } /// Opens the window as a child of another window. - public func showWindow() { - guard let window = window, - let parentWindow = NSApplication.shared.keyWindow - else { return } - + public func showWindow(attachedTo parentWindow: NSWindow) { + guard let window = window else { return } parentWindow.addChildWindow(window, ordered: .above) // Close on window switch observer diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 87b05d9df..454c6e0e7 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -196,9 +196,6 @@ public class TextViewController: NSViewController { ) } - /// The `SuggestionController` lets us display the autocomplete items - public lazy var suggestionController: SuggestionController = SuggestionController() - // MARK: Init public init( From 76a02066eedb530b66d309c10ef29c37c4adcf5c Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 23 Jul 2025 11:16:49 -0500 Subject: [PATCH 17/24] Theme the window --- .../SuggestionViewController.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionViewController.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionViewController.swift index 7089b04c5..22c6ad8ed 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionViewController.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionViewController.swift @@ -71,12 +71,26 @@ class SuggestionViewController: NSViewController { super.viewWillAppear() resetScrollPosition() tableView.reloadData() + if let controller = model?.activeTextView { + styleView(using: controller) + } } func styleView(using controller: TextViewController) { switch controller.systemAppearance { case .aqua: - tintView.layer?.backgroundColor = controller.theme.background.withAlphaComponent(0.3).cgColor + let color = controller.theme.background + if color != .clear { + let newColor = NSColor( + red: color.redComponent * 0.95, + green: color.greenComponent * 0.95, + blue: color.blueComponent * 0.95, + alpha: 1.0 + ) + tintView.layer?.backgroundColor = newColor.cgColor + } else { + tintView.layer?.backgroundColor = .clear + } case .darkAqua: tintView.layer?.backgroundColor = controller.theme.background.cgColor default: From 0a0b10a5b5bc21dedd190bbfd7f7140c209f0d64 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 24 Jul 2025 14:21:50 -0500 Subject: [PATCH 18/24] Move Suggestion UI Into CodeEditSourceEditor --- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../Views/MockCompletionDelegate.swift | 76 ++++++++++++---- .../AutoCompleteCoordinatorProtocol.swift | 11 --- .../CodeSuggestion/CodeSuggestionEntry.swift | 13 --- .../{ => Model}/CodeSuggestionDelegate.swift | 0 .../Model/CodeSuggestionEntry.swift | 25 +++++ .../{ => Model}/SuggestionViewModel.swift | 0 .../TableView/CodeSuggestionLabelView.swift | 51 +++++++++++ .../CodeSuggestionRowView.swift | 0 .../{ => TableView}/NoSlotScroller.swift | 0 .../SuggestionViewController.swift | 91 ++++++++++++------- .../SuggestionController+Window.swift | 74 ++++++--------- .../{ => Window}/SuggestionController.swift | 40 ++++---- 13 files changed, 243 insertions(+), 142 deletions(-) delete mode 100644 Sources/CodeEditSourceEditor/CodeSuggestion/AutoCompleteCoordinatorProtocol.swift delete mode 100644 Sources/CodeEditSourceEditor/CodeSuggestion/CodeSuggestionEntry.swift rename Sources/CodeEditSourceEditor/CodeSuggestion/{ => Model}/CodeSuggestionDelegate.swift (100%) create mode 100644 Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionEntry.swift rename Sources/CodeEditSourceEditor/CodeSuggestion/{ => Model}/SuggestionViewModel.swift (100%) create mode 100644 Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionLabelView.swift rename Sources/CodeEditSourceEditor/CodeSuggestion/{ => TableView}/CodeSuggestionRowView.swift (100%) rename Sources/CodeEditSourceEditor/CodeSuggestion/{ => TableView}/NoSlotScroller.swift (100%) rename Sources/CodeEditSourceEditor/CodeSuggestion/{ => TableView}/SuggestionViewController.swift (64%) rename Sources/CodeEditSourceEditor/CodeSuggestion/{ => Window}/SuggestionController+Window.swift (60%) rename Sources/CodeEditSourceEditor/CodeSuggestion/{ => Window}/SuggestionController.swift (84%) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 062be1347..1a0e8e9de 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", - "version" : "1.2.0" + "revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341", + "version" : "1.2.1" } }, { diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift index 2e4db0efc..117e7818d 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift @@ -5,27 +5,71 @@ // Created by Khan Winter on 7/22/25. // -import AppKit +import SwiftUI import CodeEditSourceEditor import CodeEditTextView +private let text = [ + "Lorem", + "ipsum", + "dolor", + "sit", + "amet,", + "consectetur", + "adipiscing", + "elit.", + "Ut", + "condimentum", + "dictum", + "malesuada.", + "Praesent", + "ut", + "imperdiet", + "nulla.", + "Vivamus", + "feugiat,", + "ante", + "non", + "sagittis", + "pellentesque,", + "dui", + "massa", + "consequat", + "odio,", + "ac", + "vestibulum", + "augue", + "erat", + "et", + "nunc." +] + class MockCompletionDelegate: CodeSuggestionDelegate, ObservableObject { class Suggestion: CodeSuggestionEntry { - let text: String - var view: NSView { - let view = NSTextField(string: text) - view.isEditable = false - view.isSelectable = false - view.isBezeled = false - view.isBordered = false - view.backgroundColor = .clear - view.textColor = .black - return view - } + var label: String + var detail: String? + var pathComponents: [String]? { nil } + var targetPosition: CursorPosition? { nil } + var sourcePreview: String? { nil } + var image: Image = Image(systemName: "dot.square.fill") + var imageColor: Color = .gray + var deprecated: Bool = false init(text: String) { - self.text = text + self.label = text + } + } + + private func randomSuggestions() -> [Suggestion] { + let count = Int.random(in: 0..<20) + var suggestions: [Suggestion] = [] + for _ in 0.. (windowPosition: CursorPosition, items: [CodeSuggestionEntry])? { try? await Task.sleep(for: .seconds(0.2)) - return (cursorPosition, [Suggestion(text: "Hello"), Suggestion(text: "World")]) + return (cursorPosition, randomSuggestions()) } func completionOnCursorMove( @@ -41,7 +85,7 @@ class MockCompletionDelegate: CodeSuggestionDelegate, ObservableObject { cursorPosition: CursorPosition ) -> [CodeSuggestionEntry]? { if Bool.random() { - [Suggestion(text: "Another one")] + randomSuggestions() } else { nil } @@ -56,7 +100,7 @@ class MockCompletionDelegate: CodeSuggestionDelegate, ObservableObject { return } textView.textView.undoManager?.beginUndoGrouping() - textView.textView.insertText(suggestion.text) + textView.textView.insertText(suggestion.label) textView.textView.undoManager?.endUndoGrouping() } } diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/AutoCompleteCoordinatorProtocol.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/AutoCompleteCoordinatorProtocol.swift deleted file mode 100644 index 4956c7083..000000000 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/AutoCompleteCoordinatorProtocol.swift +++ /dev/null @@ -1,11 +0,0 @@ -// -// AutoCompleteCoordinatorProtocol.swift -// CodeEditSourceEditor -// -// Created by Abe Malla on 4/8/25. -// - -public protocol AutoCompleteCoordinatorProtocol: TextViewCoordinator { - func fetchCompletions() async throws -> [CodeSuggestionEntry] - func showAutocompleteWindow() -} diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/CodeSuggestionEntry.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/CodeSuggestionEntry.swift deleted file mode 100644 index 007da7b93..000000000 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/CodeSuggestionEntry.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// CodeSuggestionEntry.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 7/22/25. -// - -import AppKit - -/// Represents an item that can be displayed in the code suggestion view -public protocol CodeSuggestionEntry { - var view: NSView { get } -} diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/CodeSuggestionDelegate.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionDelegate.swift similarity index 100% rename from Sources/CodeEditSourceEditor/CodeSuggestion/CodeSuggestionDelegate.swift rename to Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionDelegate.swift diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionEntry.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionEntry.swift new file mode 100644 index 000000000..981ad7dc5 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionEntry.swift @@ -0,0 +1,25 @@ +// +// CodeSuggestionEntry.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 7/22/25. +// + +import AppKit +import SwiftUI + +/// Represents an item that can be displayed in the code suggestion view +public protocol CodeSuggestionEntry { + var label: String { get } + var detail: String? { get } + + /// Leave as `nil` if the link is in the same document. + var pathComponents: [String]? { get } + var targetPosition: CursorPosition? { get } + var sourcePreview: String? { get } + + var image: Image { get } + var imageColor: Color { get } + + var deprecated: Bool { get } +} diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionViewModel.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift similarity index 100% rename from Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionViewModel.swift rename to Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionLabelView.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionLabelView.swift new file mode 100644 index 000000000..bf1bb2aba --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionLabelView.swift @@ -0,0 +1,51 @@ +// +// CodeSuggestionLabelView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 7/24/25. +// + +import AppKit +import SwiftUI + +struct CodeSuggestionLabelView: View { + let suggestion: CodeSuggestionEntry + let labelColor: NSColor + let secondaryLabelColor: NSColor + let font: NSFont + + var body: some View { + HStack(alignment: .center, spacing: 2) { + suggestion.image + .font(.system(size: font.pointSize + 2)) + .foregroundStyle( + .white, + suggestion.deprecated ? .gray : suggestion.imageColor + ) + + // Main label + HStack(spacing: 0) { + Text(suggestion.label) + .foregroundStyle(suggestion.deprecated ? Color(secondaryLabelColor) : Color(labelColor)) + + if let detail = suggestion.detail { + Text(detail) + .foregroundStyle(Color(secondaryLabelColor)) + } + } + .font(Font(font)) + + Spacer(minLength: 0) + + // Right side indicators + if suggestion.deprecated { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: font.pointSize + 2)) + .foregroundStyle(Color(labelColor), Color(secondaryLabelColor)) + } + } + .padding(.vertical, 3) + .padding(.horizontal, 13) + .buttonStyle(PlainButtonStyle()) + } +} diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/CodeSuggestionRowView.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionRowView.swift similarity index 100% rename from Sources/CodeEditSourceEditor/CodeSuggestion/CodeSuggestionRowView.swift rename to Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionRowView.swift diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/NoSlotScroller.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/NoSlotScroller.swift similarity index 100% rename from Sources/CodeEditSourceEditor/CodeSuggestion/NoSlotScroller.swift rename to Sources/CodeEditSourceEditor/CodeSuggestion/TableView/NoSlotScroller.swift diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionViewController.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift similarity index 64% rename from Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionViewController.swift rename to Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift index 22c6ad8ed..1e70be56e 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionViewController.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift @@ -6,15 +6,16 @@ // import AppKit +import SwiftUI import Combine class SuggestionViewController: NSViewController { var tableView: NSTableView! var scrollView: NSScrollView! - var tintView: NSView! var noItemsLabel: NSTextField! var itemObserver: AnyCancellable? + weak var model: SuggestionViewModel? { didSet { itemObserver?.cancel() @@ -28,13 +29,7 @@ class SuggestionViewController: NSViewController { super.loadView() view.wantsLayer = true view.layer?.cornerRadius = 8.5 - view.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor - - tintView = NSView() - tintView.translatesAutoresizingMaskIntoConstraints = false - tintView.wantsLayer = true - tintView.layer?.cornerRadius = 8.5 - view.addSubview(tintView) + view.layer?.backgroundColor = .clear tableView = NSTableView() configureTableView() @@ -46,24 +41,19 @@ class SuggestionViewController: NSViewController { noItemsLabel.alignment = .center noItemsLabel.translatesAutoresizingMaskIntoConstraints = false noItemsLabel.isHidden = false - // TODO: GET FONT SIZE FROM THEME - noItemsLabel.font = .monospacedSystemFont(ofSize: 12, weight: .regular) - tintView.addSubview(noItemsLabel) - tintView.addSubview(scrollView) + view.addSubview(noItemsLabel) + view.addSubview(scrollView) NSLayoutConstraint.activate([ - tintView.topAnchor.constraint(equalTo: view.topAnchor), - tintView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - tintView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - tintView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - - noItemsLabel.centerXAnchor.constraint(equalTo: tintView.centerXAnchor), - noItemsLabel.centerYAnchor.constraint(equalTo: tintView.centerYAnchor), - scrollView.topAnchor.constraint(equalTo: tintView.topAnchor), - scrollView.leadingAnchor.constraint(equalTo: tintView.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: tintView.trailingAnchor), - scrollView.bottomAnchor.constraint(equalTo: tintView.bottomAnchor) + noItemsLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + noItemsLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: 10), + noItemsLabel.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -10), + + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) } @@ -77,6 +67,7 @@ class SuggestionViewController: NSViewController { } func styleView(using controller: TextViewController) { + noItemsLabel.font = controller.font switch controller.systemAppearance { case .aqua: let color = controller.theme.background @@ -87,15 +78,42 @@ class SuggestionViewController: NSViewController { blue: color.blueComponent * 0.95, alpha: 1.0 ) - tintView.layer?.backgroundColor = newColor.cgColor + view.layer?.backgroundColor = newColor.cgColor } else { - tintView.layer?.backgroundColor = .clear + view.layer?.backgroundColor = .clear } case .darkAqua: - tintView.layer?.backgroundColor = controller.theme.background.cgColor + view.layer?.backgroundColor = controller.theme.background.cgColor default: return } + + guard model?.items.isEmpty == false else { + let size = NSSize(width: 256, height: noItemsLabel.fittingSize.height + 20) + preferredContentSize = size + view.window?.setContentSize(size) + view.window?.contentMinSize = size + view.window?.contentMaxSize = size + return + } + guard let rowView = tableView.view(atColumn: 0, row: 0, makeIfNecessary: true) else { + return + } + let rowHeight = rowView.fittingSize.height + + let numberOfVisibleRows = min(CGFloat(model?.items.count ?? 0), SuggestionController.MAX_VISIBLE_ROWS) + let newHeight = rowHeight * numberOfVisibleRows + SuggestionController.WINDOW_PADDING * 2 + + let maxLength = min((model?.items.max(by: { $0.label.count < $1.label.count })?.label.count ?? 16) + 4, 48) + let newWidth = CGFloat(maxLength) * controller.font.charWidth + + view.constraints.filter({ $0.firstAnchor == view.heightAnchor }).forEach { $0.isActive = false } + view.heightAnchor.constraint(equalToConstant: newHeight).isActive = true + + preferredContentSize = NSSize(width: newWidth, height: newHeight) + view.window?.setContentSize(NSSize(width: newWidth, height: newHeight)) + view.window?.contentMinSize = NSSize(width: newWidth, height: newHeight) + view.window?.contentMaxSize = NSSize(width: .infinity, height: newHeight) } func configureTableView() { @@ -107,9 +125,7 @@ class SuggestionViewController: NSViewController { tableView.allowsEmptySelection = false tableView.selectionHighlightStyle = .regular tableView.style = .plain - tableView.usesAutomaticRowHeights = false - tableView.rowSizeStyle = .custom - tableView.rowHeight = 21 + tableView.usesAutomaticRowHeights = true tableView.gridStyleMask = [] tableView.target = self tableView.action = #selector(tableViewClicked(_:)) @@ -157,7 +173,7 @@ class SuggestionViewController: NSViewController { clipView.scroll(to: NSPoint(x: 0, y: -SuggestionController.WINDOW_PADDING)) // Select the first item - if !(model?.items.isEmpty ?? true) { + if model?.items.isEmpty == false { tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) } } @@ -179,8 +195,19 @@ extension SuggestionViewController: NSTableViewDataSource, NSTableViewDelegate { } public func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { - guard row >= 0, row < model?.items.count ?? 0 else { return nil } - return model?.items[row].view + guard let model = model, + row >= 0, row < model.items.count, + let textView = model.activeTextView else { + return nil + } + return NSHostingView( + rootView: CodeSuggestionLabelView( + suggestion: model.items[row], + labelColor: textView.theme.text.color, + secondaryLabelColor: textView.theme.comments.color, + font: textView.font + ) + ) } public func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift similarity index 60% rename from Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift rename to Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift index 5838fffe5..6a2be7049 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift @@ -63,7 +63,7 @@ extension SuggestionController { static func makeWindow() -> NSWindow { let window = NSWindow( - contentRect: NSRect(origin: .zero, size: self.DEFAULT_SIZE), + contentRect: .zero, styleMask: [.resizable, .fullSizeContentView, .nonactivatingPanel, .utilityWindow], backing: .buffered, defer: false @@ -79,56 +79,36 @@ extension SuggestionController { window.tabbingMode = .disallowed window.hidesOnDeactivate = true window.backgroundColor = .clear - window.minSize = Self.DEFAULT_SIZE return window } /// 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 - } - - // Update window dimensions - let numberOfVisibleRows = min(CGFloat(model.items.count), Self.MAX_VISIBLE_ROWS) - let newHeight = model.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) - } - - /// 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 - } +// func updateSuggestionWindowAndContents(font: NSFont, rowHeight: CGFloat) { +// guard let window = self.window else { +// return +// } +// let newSize = windowSize(font: font, rowHeight: rowHeight) +// 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: newSize.width, +// height: newSize.height +// ) +// window.setFrame(newFrame, display: true) +// } else { +// // When window is below cursor, maintain the top position +// window.setContentSize(newSize) +// } +// +// // Don't allow vertical resizing +// window.maxSize = NSSize(width: CGFloat.infinity, height: newSize.height) +// window.minSize = NSSize(width: newSize.width, height: newSize.height) +// } } diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift similarity index 84% rename from Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift rename to Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift index e617cc5d6..140b6e2b2 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift @@ -14,25 +14,15 @@ public final class SuggestionController: NSWindowController { // MARK: - Properties - static var DEFAULT_SIZE: NSSize { - NSSize( - width: 256, // TODO: DOES MIN WIDTH DEPEND ON FONT SIZE? - height: rowsToWindowHeight(for: 1) - ) - } - - /// Whether the suggestion window is visibile + /// Whether the suggestion window is visible var isVisible: Bool { window?.isVisible ?? false } - var itemObserver: AnyCancellable? var model: SuggestionViewModel = SuggestionViewModel() // 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 @@ -60,28 +50,36 @@ public final class SuggestionController: NSWindowController { if window.isVisible { window.close() } - - itemObserver = model.$items.receive(on: DispatchQueue.main).sink { [weak self] _ in - self?.onItemsUpdated() - } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + // MARK: - Show Completions + func showCompletions( textView: TextViewController, delegate: CodeSuggestionDelegate, - cursorPosition: CursorPosition + cursorPosition: CursorPosition, + asPopover: Bool = false ) { model.showCompletions( textView: textView, delegate: delegate, cursorPosition: cursorPosition ) { parentWindow, cursorRect in - self.showWindow(attachedTo: parentWindow) - self.constrainWindowToScreenEdges(cursorRect: cursorRect) + if asPopover { + let windowPosition = parentWindow.convertFromScreen(cursorRect) + let textViewPosition = textView.textView.convert(windowPosition, from: nil) + let popover = NSPopover() + popover.behavior = .transient + popover.contentViewController = self.contentViewController + popover.show(relativeTo: textViewPosition, of: textView.textView, preferredEdge: .maxY) + } else { + self.showWindow(attachedTo: parentWindow) + self.constrainWindowToScreenEdges(cursorRect: cursorRect) + } (self.contentViewController as? SuggestionViewController)?.styleView(using: textView) } } @@ -117,9 +115,7 @@ public final class SuggestionController: NSWindowController { super.close() } - private func onItemsUpdated() { - updateSuggestionWindowAndContents() - } + // MARK: - Events private func setupEventMonitors() { localEventMonitor = NSEvent.addLocalMonitorForEvents( @@ -170,6 +166,8 @@ public final class SuggestionController: NSWindowController { } } + // MARK: - Cursors Updated + func cursorsUpdated( textView: TextViewController, delegate: CodeSuggestionDelegate, From 058e1657478e3417356f0adace7f4e10b4387676 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 24 Jul 2025 14:25:24 -0500 Subject: [PATCH 19/24] Remove Unused Method --- .../Window/SuggestionController+Window.swift | 29 ------------------- 1 file changed, 29 deletions(-) diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift index 6a2be7049..9c7547925 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift @@ -82,33 +82,4 @@ extension SuggestionController { return window } - - /// 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(font: NSFont, rowHeight: CGFloat) { -// guard let window = self.window else { -// return -// } -// let newSize = windowSize(font: font, rowHeight: rowHeight) -// 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: newSize.width, -// height: newSize.height -// ) -// window.setFrame(newFrame, display: true) -// } else { -// // When window is below cursor, maintain the top position -// window.setContentSize(newSize) -// } -// -// // Don't allow vertical resizing -// window.maxSize = NSSize(width: CGFloat.infinity, height: newSize.height) -// window.minSize = NSSize(width: newSize.width, height: newSize.height) -// } } From 64115de98f625990b61d18d6b8792b9c7b7c047e Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 24 Jul 2025 15:04:09 -0500 Subject: [PATCH 20/24] Use Default Window Background Color --- .../CodeSuggestion/Window/SuggestionController+Window.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift index 9c7547925..d96369fd5 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift @@ -78,7 +78,6 @@ extension SuggestionController { window.isOpaque = false window.tabbingMode = .disallowed window.hidesOnDeactivate = true - window.backgroundColor = .clear return window } From e00a49d11cba6311015c2ff1ab7dd9bc2359caea Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 25 Jul 2025 14:10:13 -0500 Subject: [PATCH 21/24] Round Window Corners, Adjust Origin When Above Cursor --- .../TableView/CodeSuggestionLabelView.swift | 2 +- .../TableView/SuggestionViewController.swift | 44 +++++++++++++------ .../Window/SuggestionController+Window.swift | 15 +++++++ 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionLabelView.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionLabelView.swift index bf1bb2aba..748856a38 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionLabelView.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionLabelView.swift @@ -24,7 +24,7 @@ struct CodeSuggestionLabelView: View { ) // Main label - HStack(spacing: 0) { + HStack(spacing: font.charWidth) { Text(suggestion.label) .foregroundStyle(suggestion.deprecated ? Color(secondaryLabelColor) : Color(labelColor)) diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift index 1e70be56e..c9b4524b8 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift @@ -10,6 +10,7 @@ import SwiftUI import Combine class SuggestionViewController: NSViewController { + var tintView: NSView! var tableView: NSTableView! var scrollView: NSScrollView! var noItemsLabel: NSTextField! @@ -29,7 +30,14 @@ class SuggestionViewController: NSViewController { super.loadView() view.wantsLayer = true view.layer?.cornerRadius = 8.5 - view.layer?.backgroundColor = .clear + view.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor + + tintView = NSView() + tintView.translatesAutoresizingMaskIntoConstraints = false + tintView.wantsLayer = true + tintView.layer?.cornerRadius = 8.5 + tintView.layer?.backgroundColor = .clear + view.addSubview(tintView) tableView = NSTableView() configureTableView() @@ -46,6 +54,11 @@ class SuggestionViewController: NSViewController { view.addSubview(scrollView) NSLayoutConstraint.activate([ + tintView.topAnchor.constraint(equalTo: view.topAnchor), + tintView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + tintView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tintView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + noItemsLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), noItemsLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: 10), noItemsLabel.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -10), @@ -78,22 +91,23 @@ class SuggestionViewController: NSViewController { blue: color.blueComponent * 0.95, alpha: 1.0 ) - view.layer?.backgroundColor = newColor.cgColor + tintView.layer?.backgroundColor = newColor.cgColor } else { - view.layer?.backgroundColor = .clear + tintView.layer?.backgroundColor = .clear } case .darkAqua: - view.layer?.backgroundColor = controller.theme.background.cgColor + tintView.layer?.backgroundColor = controller.theme.background.cgColor default: return } + updateSize(using: controller) + } + func updateSize(using controller: TextViewController) { guard model?.items.isEmpty == false else { let size = NSSize(width: 256, height: noItemsLabel.fittingSize.height + 20) preferredContentSize = size - view.window?.setContentSize(size) - view.window?.contentMinSize = size - view.window?.contentMaxSize = size + (self.view.window?.windowController as? SuggestionController)?.updateWindowSize(newSize: size) return } guard let rowView = tableView.view(atColumn: 0, row: 0, makeIfNecessary: true) else { @@ -104,16 +118,17 @@ class SuggestionViewController: NSViewController { let numberOfVisibleRows = min(CGFloat(model?.items.count ?? 0), SuggestionController.MAX_VISIBLE_ROWS) let newHeight = rowHeight * numberOfVisibleRows + SuggestionController.WINDOW_PADDING * 2 - let maxLength = min((model?.items.max(by: { $0.label.count < $1.label.count })?.label.count ?? 16) + 4, 48) + let maxLength = min( + (model?.items.reduce(0, { max($0, $1.label.count + ($1.detail?.count ?? 0)) }) ?? 16) + 4, + 64 + ) let newWidth = CGFloat(maxLength) * controller.font.charWidth view.constraints.filter({ $0.firstAnchor == view.heightAnchor }).forEach { $0.isActive = false } view.heightAnchor.constraint(equalToConstant: newHeight).isActive = true - preferredContentSize = NSSize(width: newWidth, height: newHeight) - view.window?.setContentSize(NSSize(width: newWidth, height: newHeight)) - view.window?.contentMinSize = NSSize(width: newWidth, height: newHeight) - view.window?.contentMaxSize = NSSize(width: .infinity, height: newHeight) + let newSize = NSSize(width: newWidth, height: newHeight) + (self.view.window?.windowController as? SuggestionController)?.updateWindowSize(newSize: newSize) } func configureTableView() { @@ -158,6 +173,9 @@ class SuggestionViewController: NSViewController { scrollView.isHidden = model.items.isEmpty } tableView.reloadData() + if let activeTextView = model?.activeTextView { + updateSize(using: activeTextView) + } } @objc private func tableViewClicked(_ sender: Any?) { @@ -204,7 +222,7 @@ extension SuggestionViewController: NSTableViewDataSource, NSTableViewDelegate { rootView: CodeSuggestionLabelView( suggestion: model.items[row], labelColor: textView.theme.text.color, - secondaryLabelColor: textView.theme.comments.color, + secondaryLabelColor: textView.theme.text.color.withAlphaComponent(0.5), font: textView.font ) ) diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift index d96369fd5..f54e103ae 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift @@ -59,6 +59,20 @@ extension SuggestionController { } } + func updateWindowSize(newSize: NSSize) { + guard let window else { return } + let oldFrame = window.frame + + window.minSize = newSize + window.maxSize = NSSize(width: CGFloat.infinity, height: newSize.height) + + window.setContentSize(newSize) + + if isWindowAboveCursor && oldFrame.size.height != newSize.height { + window.setFrameOrigin(oldFrame.origin) + } + } + // MARK: - Private Methods static func makeWindow() -> NSWindow { @@ -78,6 +92,7 @@ extension SuggestionController { window.isOpaque = false window.tabbingMode = .disallowed window.hidesOnDeactivate = true + window.backgroundColor = .clear return window } From 3135931c1a776e107c7ccefdcd852820e1bc0d8d Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 25 Jul 2025 14:10:22 -0500 Subject: [PATCH 22/24] Hide Suggestion Window When Escaped --- .../Controller/TextViewController+Lifecycle.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift index be30cf26c..eb075c0f0 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift @@ -261,7 +261,12 @@ extension TextViewController { } private func handleShowCompletions(_ event: NSEvent) -> NSEvent? { - if let completionDelegate = self.completionDelegate, let cursorPosition = cursorPositions.first { + if let completionDelegate = self.completionDelegate, + let cursorPosition = cursorPositions.first { + if SuggestionController.shared.isVisible { + SuggestionController.shared.close() + return event + } SuggestionController.shared.showCompletions( textView: self, delegate: completionDelegate, From a678f180257a564ad07bf38469983945738dba53 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 25 Jul 2025 14:10:35 -0500 Subject: [PATCH 23/24] Ignore Cursor Change When Request In Progress --- .../CodeSuggestion/Model/SuggestionViewModel.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift index 840d6c4f9..91fe22fec 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift @@ -30,6 +30,8 @@ final class SuggestionViewModel: ObservableObject { self.activeTextView = textView itemsRequestTask = Task { + defer { itemsRequestTask = nil } + do { guard let completionItems = await delegate.completionSuggestionsRequested( textView: textView, @@ -67,6 +69,8 @@ final class SuggestionViewModel: ObservableObject { position: CursorPosition, close: () -> Void ) { + guard itemsRequestTask == nil else { return } + if activeTextView !== textView { close() return @@ -75,7 +79,8 @@ final class SuggestionViewModel: ObservableObject { guard let newItems = delegate.completionOnCursorMove( textView: textView, cursorPosition: position - ) else { + ), + !newItems.isEmpty else { close() return } From ac1b50c3f9b0a18b7fb25b39a22b18761299872d Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 25 Jul 2025 14:10:39 -0500 Subject: [PATCH 24/24] Update Conformances --- .../Views/MockCompletionDelegate.swift | 21 ++++++++++++------- .../Model/CodeSuggestionDelegate.swift | 2 +- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift index 117e7818d..e08d85921 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift @@ -60,8 +60,8 @@ class MockCompletionDelegate: CodeSuggestionDelegate, ObservableObject { } } - private func randomSuggestions() -> [Suggestion] { - let count = Int.random(in: 0..<20) + private func randomSuggestions(_ count: Int? = nil) -> [Suggestion] { + let count = count ?? Int.random(in: 0..<20) var suggestions: [Suggestion] = [] for _ in 0.. [CodeSuggestionEntry]? { - if Bool.random() { - randomSuggestions() - } else { - nil + moveCount += 1 + switch moveCount { + case 1: + return randomSuggestions(2) + case 2: + return randomSuggestions(20) + default: + moveCount = 0 + return nil } } func completionWindowApplyCompletion( item: CodeSuggestionEntry, textView: TextViewController, - cursorPosition: CursorPosition + cursorPosition: CursorPosition? ) { guard let suggestion = item as? Suggestion else { return diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionDelegate.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionDelegate.swift index 8a3b3b29a..c313ffcdb 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionDelegate.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionDelegate.swift @@ -25,7 +25,7 @@ public protocol CodeSuggestionDelegate: AnyObject { func completionWindowApplyCompletion( item: CodeSuggestionEntry, textView: TextViewController, - cursorPosition: CursorPosition + cursorPosition: CursorPosition? ) // Optional func completionWindowDidSelect(item: CodeSuggestionEntry)