From 36b86c417f9b504c424214f0959085ec46c6a135 Mon Sep 17 00:00:00 2001 From: Abe M Date: Sat, 19 Oct 2024 22:31:19 -0700 Subject: [PATCH 01/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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 16291541183d29a2cb2fa3aea580d6f341ba6245 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 24 Jul 2025 11:32:55 -0500 Subject: [PATCH 18/41] First Iteration --- .../xcshareddata/swiftpm/Package.resolved | 9 -- Package.swift | 5 +- .../TextViewController+Lifecycle.swift | 38 ++++- .../Controller/TextViewController.swift | 12 +- .../JumpToDefinitionDelegate.swift | 13 ++ .../JumpToDefinitionLink.swift | 41 ++++++ .../JumpToDefinitionLinkList.swift | 85 +++++++++++ .../JumpToDefinitionModel.swift | 134 ++++++++++++++++++ .../SourceEditorTextView.swift | 21 +++ .../TreeSitter/TreeSitterClient+Query.swift | 46 ++++++ .../TreeSitterClient+Temporary.swift | 14 ++ .../TreeSitter/TreeSitterExecutor.swift | 11 ++ 12 files changed, 410 insertions(+), 19 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionDelegate.swift create mode 100644 Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLink.swift create mode 100644 Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLinkList.swift create mode 100644 Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift create mode 100644 Sources/CodeEditSourceEditor/SupportingViews/SourceEditorTextView.swift create mode 100644 Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Temporary.swift 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..c511a9f74 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -18,15 +18,6 @@ "version" : "0.2.3" } }, - { - "identity" : "codeedittextview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", - "state" : { - "revision" : "e7f1580a8075af84c349fb8c66fbd2776ff5cb1d", - "version" : "0.12.0" - } - }, { "identity" : "rearrange", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 1fc3520aa..129e212c1 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.12.0" +// url: "https://github.com/CodeEditApp/CodeEditTextView.git", +// from: "0.12.0" + path: "../CodeEditTextView" ), // tree-sitter languages .package( diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift index be30cf26c..7bb3ca0bf 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift @@ -189,7 +189,9 @@ extension TextViewController { } func setUpKeyBindings(eventMonitor: inout Any?) { - eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event -> NSEvent? in + eventMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.keyDown, .flagsChanged, .mouseMoved] + ) { [weak self] event -> NSEvent? in guard let self = self else { return event } // Check if this window is key and if the text view is the first responder @@ -198,14 +200,36 @@ extension TextViewController { // Only handle commands if this is the key window and text view is first responder guard isKeyWindow && isFirstResponder else { return event } - let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - let tabKey: UInt16 = 0x30 - if event.keyCode == tabKey { - return self.handleTab(event: event, modifierFalgs: modifierFlags.rawValue) - } else { - return self.handleCommand(event: event, modifierFlags: modifierFlags.rawValue) + switch event.type { + case .keyDown: + let tabKey: UInt16 = 0x30 + + if event.keyCode == tabKey { + return self.handleTab(event: event, modifierFalgs: modifierFlags.rawValue) + } else { + return self.handleCommand(event: event, modifierFlags: modifierFlags.rawValue) + } + case .flagsChanged: + if modifierFlags.contains(.command), + let coords = view.window?.convertPoint(fromScreen: NSEvent.mouseLocation) { + self.jumpToDefinitionModel?.mouseHovered(windowCoordinates: coords) + } + + if !modifierFlags.contains(.command) { + self.jumpToDefinitionModel?.cancelHover() + } + return event + case .mouseMoved: + guard modifierFlags.contains(.command) else { + self.jumpToDefinitionModel?.cancelHover() + return event + } + self.jumpToDefinitionModel?.mouseHovered(windowCoordinates: event.locationInWindow) + return event + default: + return event } } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 454c6e0e7..64429d87c 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -180,6 +180,8 @@ public class TextViewController: NSViewController { /// Filters used when applying edits.. var textFilters: [TextFormation.Filter] = [] + var jumpToDefinitionModel: JumpToDefinitionModel? + var cancellables = Set() /// The trailing inset for the editor. Grows when line wrapping is disabled or when the minimap is shown. @@ -223,7 +225,7 @@ public class TextViewController: NSViewController { self.treeSitterClient = client } - self.textView = TextView( + self.textView = SourceEditorTextView( string: string, font: font, textColor: theme.text.color, @@ -242,6 +244,14 @@ public class TextViewController: NSViewController { $0.prepareCoordinator(controller: self) } self.textCoordinators = coordinators.map { WeakCoordinator($0) } + + if let treeSitterClient { + jumpToDefinitionModel = JumpToDefinitionModel( + textView: textView, + treeSitterClient: treeSitterClient, + delegate: nil + ) + } } required init?(coder: NSCoder) { diff --git a/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionDelegate.swift b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionDelegate.swift new file mode 100644 index 000000000..5951adf9a --- /dev/null +++ b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionDelegate.swift @@ -0,0 +1,13 @@ +// +// JumpToDefinitionDelegate.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 7/23/25. +// + +import Foundation + +public protocol JumpToDefinitionDelegate: AnyObject { + func queryLinks(forRange range: NSRange) async -> [JumpToDefinitionLink]? + func openLink(url: URL, targetRange: NSRange) +} diff --git a/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLink.swift b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLink.swift new file mode 100644 index 000000000..8e9ef49ac --- /dev/null +++ b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLink.swift @@ -0,0 +1,41 @@ +// +// JumpToDefinitionLink.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 7/23/25. +// + +import Foundation +import SwiftUI + +public struct JumpToDefinitionLink: Identifiable, Sendable { + public var id: String { url?.absoluteString ?? "\(targetRange)" } + /// Leave as `nil` if the link is in the same document. + public let url: URL? + public let targetPosition: CursorPosition + public let targetRange: NSRange + + public let typeName: String + public let sourcePreview: String + + public let image: Image + public let imageColor: Color + + public init( + url: URL?, + targetPosition: CursorPosition, + targetRange: NSRange, + typeName: String, + sourcePreview: String, + image: Image = Image(systemName: "dot.square.fill"), + imageColor: Color = Color(NSColor.lightGray) + ) { + self.url = url + self.targetPosition = targetPosition + self.targetRange = targetRange + self.typeName = typeName + self.sourcePreview = sourcePreview + self.image = image + self.imageColor = imageColor + } +} diff --git a/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLinkList.swift b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLinkList.swift new file mode 100644 index 000000000..cce858e25 --- /dev/null +++ b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLinkList.swift @@ -0,0 +1,85 @@ +// +// JumpToDefinitionLinkList.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 7/23/25. +// + +import SwiftUI + +struct JumpToDefinitionLinkList: View { + let items: [JumpToDefinitionLink] + let font: NSFont + let dismiss: () -> Void + let onSelect: (JumpToDefinitionLink) -> Void + + private let maxVisibleItems = 5 + + @State private var selectedRow: JumpToDefinitionLink? + + var body: some View { + VStack { + if items.count > maxVisibleItems { + ScrollView { + listStack + } + .scrollIndicators(.hidden) + } else { + listStack + } + if let selectedRow { + VStack { + Text(selectedRow.sourcePreview) + .font(Font(font)) + HStack { + ForEach(selectedRow.url?.pathComponents ?? [], id: \.self) { component in + Text(component) + Image(systemName: "chevron.compact.right") + } + } + .font(.system(size: 12)) + } + } + } + } + + @ViewBuilder private var listStack: some View { + VStack(spacing: 0) { + ForEach(items) { item in + HStack(alignment: .firstTextBaseline, spacing: 2) { + item.image + .foregroundStyle(.white, item.imageColor) + Text(item.typeName) + Spacer(minLength: 0) + } + .font(Font(font)) + .contentShape(Rectangle()) + .onTapGesture { + if let selectedRow { + onSelect(selectedRow) + } + dismiss() + } + .onHover { isHovered in + if isHovered { + selectedRow = item + } else if !isHovered && selectedRow?.id == item.id { + selectedRow = nil + } + } + } + } + } +} + +#if DEBUG + +#Preview { + JumpToDefinitionLinkList(items: [], font: .monospacedSystemFont(ofSize: 12, weight: .medium)) { + + } onSelect: { _ in + + } +} + +#endif diff --git a/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift new file mode 100644 index 000000000..dcecb207c --- /dev/null +++ b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift @@ -0,0 +1,134 @@ +// +// JumpToDefinitionModel.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 7/23/25. +// + +import AppKit +import CodeEditTextView + +/// Manages two things: +/// - Finding a range to hover when pressing `cmd` using tree-sitter. +/// - Utilizing the `JumpToDefinitionDelegate` object to perform a jump, providing it with ranges and +/// strings as necessary. +/// - Presenting a popover when multiple options exist to jump to. +@MainActor +final class JumpToDefinitionModel { + static let emphasisId = "jumpToDefinition" + + weak var delegate: JumpToDefinitionDelegate? + + private weak var textView: TextView? + private weak var treeSitterClient: TreeSitterClient? + + private(set) public var hoveredRange: NSRange? + private var hoverRequestTask: Task? + + private var jumpRequestTask: Task? + + init(textView: TextView, treeSitterClient: TreeSitterClient, delegate: JumpToDefinitionDelegate?) { + self.textView = textView + self.treeSitterClient = treeSitterClient + self.delegate = delegate + } + + // MARK: - Tree Sitter + + /// Query the tree-sitter client for a valid range to query for definitions. + /// - Parameter location: The current cursor location. + /// - Returns: A range that contains a potential identifier to look up. + private func findDefinitionRange(at location: Int) async -> NSRange? { + guard let nodes = try? await treeSitterClient?.nodesAt(location: location), + let node = nodes.first(where: { $0.node.nodeType?.contains("identifier") == true }) else { + cancelHover() + return nil + } + guard !Task.isCancelled else { return nil } + return node.node.range + } + + // MARK: - Jump Action + + /// Performs the jump action. + /// - Parameter location: The location to query the delegate for. + func performJump(at location: NSRange) { + jumpRequestTask?.cancel() + jumpRequestTask = Task { + guard let links = await delegate?.queryLinks(forRange: location), + !links.isEmpty else { + NSSound.beep() + return + } + if links.count == 1 { + let link = links[0] + if let url = link.url { + delegate?.openLink(url: url, targetRange: link.targetRange) + } else { + textView?.selectionManager.setSelectedRange(link.targetRange) + } + + textView?.scrollSelectionToVisible() + } else { + presentLinkPopover(on: location, links: links) + } + } + } + + // MARK: - Link Popover + + func presentLinkPopover(on range: NSRange, links: [JumpToDefinitionLink]) { + let halfway = range.location + (range.length / 2) + guard let textView = textView, let firstRect = textView.layoutManager.rectForOffset(halfway) else { return } + let popover = NSPopover() + popover.behavior = .transient + popover.show(relativeTo: firstRect, of: textView, preferredEdge: .minY) + } + + // MARK: - Mouse Interaction + + func mouseHovered(windowCoordinates: CGPoint) { + guard let textViewCoords = textView?.convert(windowCoordinates, from: nil), + let location = textView?.layoutManager.textOffsetAtPoint(textViewCoords), + location < textView?.textStorage.length ?? 0 else { + cancelHover() + return + } + + if hoveredRange?.contains(location) == false { + cancelHover() + } + + hoverRequestTask?.cancel() + hoverRequestTask = Task { + guard let newRange = await findDefinitionRange(at: location) else { return } + updateHoveredRange(to: newRange) + } + } + + func cancelHover() { + if (textView as? SourceEditorTextView)?.additionalCursorRects.isEmpty != true { + (textView as? SourceEditorTextView)?.additionalCursorRects = [] + textView?.resetCursorRects() + } + guard hoveredRange != nil else { return } + hoveredRange = nil + hoverRequestTask?.cancel() + textView?.emphasisManager?.removeEmphases(for: Self.emphasisId) + } + + private func updateHoveredRange(to newRange: NSRange) { + let rects = textView?.layoutManager.rectsFor(range: newRange).map { ($0, NSCursor.pointingHand) } ?? [] + (textView as? SourceEditorTextView)?.additionalCursorRects = rects + textView?.resetCursorRects() + + hoveredRange = newRange + + textView?.emphasisManager?.removeEmphases(for: Self.emphasisId) + let color = textView?.selectionManager.selectionBackgroundColor ?? .selectedTextBackgroundColor + textView?.emphasisManager?.addEmphasis( + Emphasis(range: newRange, style: .outline( color: color, fill: true)), + for: Self.emphasisId + ) + } +} diff --git a/Sources/CodeEditSourceEditor/SupportingViews/SourceEditorTextView.swift b/Sources/CodeEditSourceEditor/SupportingViews/SourceEditorTextView.swift new file mode 100644 index 000000000..1579e7e62 --- /dev/null +++ b/Sources/CodeEditSourceEditor/SupportingViews/SourceEditorTextView.swift @@ -0,0 +1,21 @@ +// +// SourceEditorTextView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 7/23/25. +// + +import AppKit +import CodeEditTextView + +final class SourceEditorTextView: TextView { + var additionalCursorRects: [(NSRect, NSCursor)] = [] + + override func resetCursorRects() { + discardCursorRects() + super.resetCursorRects() + additionalCursorRects.forEach { (rect, cursor) in + addCursorRect(rect, cursor: cursor) + } + } +} diff --git a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift index 8cd51d67c..deff47b0e 100644 --- a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift @@ -33,6 +33,15 @@ extension TreeSitterClient { return try nodesAt(range: range) } + /// Finds nodes for each language layer at the given location. + /// - Parameter location: The location to get a node for. + /// - Returns: All pairs of `Language, Node` where Node is the nearest node in the tree at the given location. + /// - Throws: A ``TreeSitterClient.Error`` error. + public func nodesAt(location: Int) async throws -> [NodeResult] { + let range = NSRange(location: location, length: 1) + return try await nodesAt(range: range) + } + /// Finds nodes in each language layer for the given range. /// - Parameter range: The range to get a node for. /// - Returns: All pairs of `Language, Node` where Node is the nearest node in the tree in the given range. @@ -51,6 +60,23 @@ extension TreeSitterClient { .throwOrReturn() } + /// Finds nodes in each language layer for the given range. + /// - Parameter range: The range to get a node for. + /// - Returns: All pairs of `Language, Node` where Node is the nearest node in the tree in the given range. + /// - Throws: A ``TreeSitterClient.Error`` error. + public func nodesAt(range: NSRange) async throws -> [NodeResult] { + try await executor.exec { + var nodes: [NodeResult] = [] + for layer in self.state?.layers ?? [] { + if let language = layer.tsLanguage, + let node = layer.tree?.rootNode?.descendant(in: range.tsRange.bytes) { + nodes.append(NodeResult(id: layer.id, language: language, node: node)) + } + } + return nodes + } + } + /// Perform a query on the tree sitter layer tree. /// - Parameters: /// - query: The query to perform. @@ -71,4 +97,24 @@ extension TreeSitterClient { }) .throwOrReturn() } + + /// Perform a query on the tree sitter layer tree. + /// - Parameters: + /// - query: The query to perform. + /// - matchingLanguages: A set of languages to limit the query to. Leave empty to not filter out any layers. + /// - Returns: Any matching nodes from the query. + public func query(_ query: Query, matchingLanguages: Set = []) async throws -> [QueryResult] { + try await executor.exec { + guard let readCallback = self.readCallback else { return [] } + var result: [QueryResult] = [] + for layer in self.state?.layers ?? [] { + guard matchingLanguages.isEmpty || matchingLanguages.contains(layer.id) else { continue } + guard let tree = layer.tree else { continue } + let cursor = query.execute(in: tree) + let resolvingCursor = cursor.resolve(with: Predicate.Context(textProvider: readCallback)) + result.append(QueryResult(id: layer.id, cursor: resolvingCursor)) + } + return result + } + } } diff --git a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Temporary.swift b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Temporary.swift new file mode 100644 index 000000000..f8f8d32af --- /dev/null +++ b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Temporary.swift @@ -0,0 +1,14 @@ +// +// TreeSitterClient+Temporary.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 7/24/25. +// + +import Foundation + +extension TreeSitterClient { + func temporaryHighlight() -> NSAttributedString { + + } +} diff --git a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterExecutor.swift b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterExecutor.swift index b9e30873c..768004628 100644 --- a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterExecutor.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterExecutor.swift @@ -124,6 +124,17 @@ final package class TreeSitterExecutor { queuedTasks.append(QueueItem(task: task, id: id, priority: priority)) } + func exec(_ priority: Priority = .access, operation: @escaping () -> T) async throws -> T{ + return try await withCheckedThrowingContinuation { continuation in + execAsync(priority: priority) { + continuation.resume(returning: operation()) + } onCancel: { + continuation.resume(throwing: CancellationError()) + } + + } + } + private func removeTask(_ id: UUID) { self.lock.withLock { self.queuedTasks.removeAll(where: { $0.id == id }) 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 19/41] 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 20/41] 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 4000b67adf1d572358a5b4d57c1207f1c68ad4c4 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 24 Jul 2025 14:28:07 -0500 Subject: [PATCH 21/41] Make Model Conform To CodeSuggestionDelegate --- .../xcshareddata/swiftpm/Package.resolved | 4 +-- .../JumpToDefinitionLink.swift | 14 ++++++---- .../JumpToDefinitionModel.swift | 26 ++++++++++++++++++- .../TreeSitterClient+Temporary.swift | 6 ++--- 4 files changed, 39 insertions(+), 11 deletions(-) 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..d312fc00e 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,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/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLink.swift b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLink.swift index 8e9ef49ac..e228cc104 100644 --- a/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLink.swift +++ b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLink.swift @@ -8,19 +8,23 @@ import Foundation import SwiftUI -public struct JumpToDefinitionLink: Identifiable, Sendable { +public struct JumpToDefinitionLink: Identifiable, Sendable, CodeSuggestionEntry { public var id: String { url?.absoluteString ?? "\(targetRange)" } /// Leave as `nil` if the link is in the same document. public let url: URL? - public let targetPosition: CursorPosition + public let targetPosition: CursorPosition? public let targetRange: NSRange - public let typeName: String - public let sourcePreview: String + public let label: String + public let sourcePreview: String? public let image: Image public let imageColor: Color + public var detail: String? { nil } + public var pathComponents: [String]? { url?.pathComponents ?? [] } + public var deprecated: Bool { false } + public init( url: URL?, targetPosition: CursorPosition, @@ -33,7 +37,7 @@ public struct JumpToDefinitionLink: Identifiable, Sendable { self.url = url self.targetPosition = targetPosition self.targetRange = targetRange - self.typeName = typeName + self.label = typeName self.sourcePreview = sourcePreview self.image = image self.imageColor = imageColor diff --git a/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift index dcecb207c..a9cdacd39 100644 --- a/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift +++ b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift @@ -49,7 +49,7 @@ final class JumpToDefinitionModel { } // MARK: - Jump Action - + /// Performs the jump action. /// - Parameter location: The location to query the delegate for. func performJump(at location: NSRange) { @@ -132,3 +132,27 @@ final class JumpToDefinitionModel { ) } } + +extension JumpToDefinitionModel: CodeSuggestionDelegate { + func completionSuggestionsRequested( + textView: TextViewController, + cursorPosition: CursorPosition + ) async -> (windowPosition: CursorPosition, items: [CodeSuggestionEntry])? { + nil + } + + nonisolated func completionOnCursorMove( + textView: TextViewController, + cursorPosition: CursorPosition + ) -> [CodeSuggestionEntry]? { + nil + } + + nonisolated func completionWindowApplyCompletion( + item: CodeSuggestionEntry, + textView: TextViewController, + cursorPosition: CursorPosition + ) { + + } +} diff --git a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Temporary.swift b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Temporary.swift index f8f8d32af..22dfe564b 100644 --- a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Temporary.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Temporary.swift @@ -8,7 +8,7 @@ import Foundation extension TreeSitterClient { - func temporaryHighlight() -> NSAttributedString { - - } +// func temporaryHighlight() -> NSAttributedString { +// +// } } From de90bbcf8b395af83e39a8e5907b420609aa3b6b Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 24 Jul 2025 15:02:43 -0500 Subject: [PATCH 22/41] Finish `JumpToDefinitionModel` --- .../Model/CodeSuggestionDelegate.swift | 1 + .../Model/SuggestionViewModel.swift | 1 + .../Window/SuggestionController.swift | 7 ++ .../Controller/TextViewController.swift | 2 +- .../JumpToDefinitionLinkList.swift | 85 ------------------- .../JumpToDefinitionModel.swift | 55 +++++++++--- 6 files changed, 54 insertions(+), 97 deletions(-) delete mode 100644 Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLinkList.swift diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionDelegate.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionDelegate.swift index 8a3b3b29a..20d1c70e0 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionDelegate.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionDelegate.swift @@ -5,6 +5,7 @@ // Created by Abe Malla on 12/26/24. // +@MainActor public protocol CodeSuggestionDelegate: AnyObject { func completionTriggerCharacters() -> Set diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift index 840d6c4f9..3e3705078 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift @@ -7,6 +7,7 @@ import AppKit +@MainActor final class SuggestionViewModel: ObservableObject { /// The items to be displayed in the window @Published var items: [CodeSuggestionEntry] = [] diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift index 140b6e2b2..90ae4b31b 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift @@ -31,6 +31,8 @@ public final class SuggestionController: NSWindowController { /// Tracks when the window is placed above the cursor var isWindowAboveCursor = false + private var popover: NSPopover? + /// An event monitor for keyboard events private var localEventMonitor: Any? /// Holds the observer for the window resign notifications @@ -76,6 +78,7 @@ public final class SuggestionController: NSWindowController { popover.behavior = .transient popover.contentViewController = self.contentViewController popover.show(relativeTo: textViewPosition, of: textView.textView, preferredEdge: .maxY) + self.popover = popover } else { self.showWindow(attachedTo: parentWindow) self.constrainWindowToScreenEdges(cursorRect: cursorRect) @@ -112,6 +115,10 @@ public final class SuggestionController: NSWindowController { public override func close() { model.willClose() removeEventMonitors() + + popover?.close() + popover = nil + super.close() } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 64429d87c..627a9354d 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -247,7 +247,7 @@ public class TextViewController: NSViewController { if let treeSitterClient { jumpToDefinitionModel = JumpToDefinitionModel( - textView: textView, + controller: self, treeSitterClient: treeSitterClient, delegate: nil ) diff --git a/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLinkList.swift b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLinkList.swift deleted file mode 100644 index cce858e25..000000000 --- a/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLinkList.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// JumpToDefinitionLinkList.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 7/23/25. -// - -import SwiftUI - -struct JumpToDefinitionLinkList: View { - let items: [JumpToDefinitionLink] - let font: NSFont - let dismiss: () -> Void - let onSelect: (JumpToDefinitionLink) -> Void - - private let maxVisibleItems = 5 - - @State private var selectedRow: JumpToDefinitionLink? - - var body: some View { - VStack { - if items.count > maxVisibleItems { - ScrollView { - listStack - } - .scrollIndicators(.hidden) - } else { - listStack - } - if let selectedRow { - VStack { - Text(selectedRow.sourcePreview) - .font(Font(font)) - HStack { - ForEach(selectedRow.url?.pathComponents ?? [], id: \.self) { component in - Text(component) - Image(systemName: "chevron.compact.right") - } - } - .font(.system(size: 12)) - } - } - } - } - - @ViewBuilder private var listStack: some View { - VStack(spacing: 0) { - ForEach(items) { item in - HStack(alignment: .firstTextBaseline, spacing: 2) { - item.image - .foregroundStyle(.white, item.imageColor) - Text(item.typeName) - Spacer(minLength: 0) - } - .font(Font(font)) - .contentShape(Rectangle()) - .onTapGesture { - if let selectedRow { - onSelect(selectedRow) - } - dismiss() - } - .onHover { isHovered in - if isHovered { - selectedRow = item - } else if !isHovered && selectedRow?.id == item.id { - selectedRow = nil - } - } - } - } - } -} - -#if DEBUG - -#Preview { - JumpToDefinitionLinkList(items: [], font: .monospacedSystemFont(ofSize: 12, weight: .medium)) { - - } onSelect: { _ in - - } -} - -#endif diff --git a/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift index a9cdacd39..0673e1ed7 100644 --- a/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift +++ b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift @@ -19,7 +19,7 @@ final class JumpToDefinitionModel { weak var delegate: JumpToDefinitionDelegate? - private weak var textView: TextView? + private weak var controller: TextViewController? private weak var treeSitterClient: TreeSitterClient? private(set) public var hoveredRange: NSRange? @@ -27,8 +27,14 @@ final class JumpToDefinitionModel { private var jumpRequestTask: Task? - init(textView: TextView, treeSitterClient: TreeSitterClient, delegate: JumpToDefinitionDelegate?) { - self.textView = textView + private var currentLinks: [JumpToDefinitionLink]? + + private var textView: TextView? { + controller?.textView + } + + init(controller: TextViewController, treeSitterClient: TreeSitterClient, delegate: JumpToDefinitionDelegate?) { + self.controller = controller self.treeSitterClient = treeSitterClient self.delegate = delegate } @@ -55,9 +61,13 @@ final class JumpToDefinitionModel { func performJump(at location: NSRange) { jumpRequestTask?.cancel() jumpRequestTask = Task { + currentLinks = nil guard let links = await delegate?.queryLinks(forRange: location), !links.isEmpty else { NSSound.beep() + if let textView { + BezelNotification.show(symbolName: "questionmark", over: textView) + } return } if links.count == 1 { @@ -79,10 +89,26 @@ final class JumpToDefinitionModel { func presentLinkPopover(on range: NSRange, links: [JumpToDefinitionLink]) { let halfway = range.location + (range.length / 2) - guard let textView = textView, let firstRect = textView.layoutManager.rectForOffset(halfway) else { return } - let popover = NSPopover() - popover.behavior = .transient - popover.show(relativeTo: firstRect, of: textView, preferredEdge: .minY) + let range = NSRange(location: halfway, length: 0) + guard let controller, + let position = controller.resolveCursorPosition(CursorPosition(range: range)) else { + return + } + currentLinks = links + SuggestionController.shared.showCompletions( + textView: controller, + delegate: self, + cursorPosition: position, + asPopover: true + ) + } + + // MARK: - Local Link + + func openLocalLink(link: JumpToDefinitionLink) { + guard let controller = controller else { return } + controller.textView.selectionManager.setSelectedRange(link.targetRange) + controller.textView.scrollSelectionToVisible() } // MARK: - Mouse Interaction @@ -138,21 +164,28 @@ extension JumpToDefinitionModel: CodeSuggestionDelegate { textView: TextViewController, cursorPosition: CursorPosition ) async -> (windowPosition: CursorPosition, items: [CodeSuggestionEntry])? { - nil + guard let links = currentLinks else { return nil } + defer { self.currentLinks = nil } + return (cursorPosition, links) } - nonisolated func completionOnCursorMove( + func completionOnCursorMove( textView: TextViewController, cursorPosition: CursorPosition ) -> [CodeSuggestionEntry]? { nil } - nonisolated func completionWindowApplyCompletion( + func completionWindowApplyCompletion( item: CodeSuggestionEntry, textView: TextViewController, cursorPosition: CursorPosition ) { - + guard let link = item as? JumpToDefinitionLink else { return } + if let url = link.url { + delegate?.openLink(url: url, targetRange: link.targetRange) + } else { + openLocalLink(link: link) + } } } 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 23/41] 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 5a27645753ed457e4558920d7d0d90911a12d710 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 24 Jul 2025 15:04:40 -0500 Subject: [PATCH 24/41] fix:lint --- .../CodeEditSourceEditor/TreeSitter/TreeSitterExecutor.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterExecutor.swift b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterExecutor.swift index 768004628..fbba65741 100644 --- a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterExecutor.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterExecutor.swift @@ -124,7 +124,7 @@ final package class TreeSitterExecutor { queuedTasks.append(QueueItem(task: task, id: id, priority: priority)) } - func exec(_ priority: Priority = .access, operation: @escaping () -> T) async throws -> T{ + func exec(_ priority: Priority = .access, operation: @escaping () -> T) async throws -> T { return try await withCheckedThrowingContinuation { continuation in execAsync(priority: priority) { continuation.resume(returning: operation()) From acea89520df6ff02c849e56a74e31f4380b5a862 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 24 Jul 2025 15:28:50 -0500 Subject: [PATCH 25/41] Finish Delegate Methods, Add Mock For Example --- .../project.pbxproj | 4 +++ .../Views/ContentView.swift | 4 ++- .../Views/MockCompletionDelegate.swift | 5 +-- .../Views/MockJumpToDefinitionDelegate.swift | 34 +++++++++++++++++++ .../Model/CodeSuggestionDelegate.swift | 2 +- .../Model/SuggestionViewModel.swift | 11 +++--- .../TableView/SuggestionViewController.swift | 2 +- .../Window/SuggestionController.swift | 5 ++- .../TextViewController+Lifecycle.swift | 8 ++++- .../Controller/TextViewController.swift | 18 +++++----- .../JumpToDefinitionModel.swift | 12 +++---- .../SourceEditor/SourceEditor.swift | 11 ++++-- 12 files changed, 87 insertions(+), 29 deletions(-) create mode 100644 Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockJumpToDefinitionDelegate.swift diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj index 32f59107d..ab7a1d158 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 */; }; + 6C730A042E32CA2A00FE1F32 /* MockJumpToDefinitionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C730A032E32CA2A00FE1F32 /* MockJumpToDefinitionDelegate.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 */ @@ -39,6 +40,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 = ""; }; + 6C730A032E32CA2A00FE1F32 /* MockJumpToDefinitionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockJumpToDefinitionDelegate.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 */ @@ -123,6 +125,7 @@ 6CF31D4D2DB6A252006A77FD /* StatusBar.swift */, 6C1365452B8A7F2D004A1D18 /* LanguagePicker.swift */, 1CB30C392DAA1C28008058A7 /* IndentPicker.swift */, + 6C730A032E32CA2A00FE1F32 /* MockJumpToDefinitionDelegate.swift */, ); path = Views; sourceTree = ""; @@ -212,6 +215,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 6C730A042E32CA2A00FE1F32 /* MockJumpToDefinitionDelegate.swift in Sources */, 6C1365482B8A7FBF004A1D18 /* EditorTheme+Default.swift in Sources */, 6C13654D2B8A821E004A1D18 /* NSColor+Hex.swift in Sources */, 6C1365302B8A7B94004A1D18 /* CodeEditSourceEditorExampleDocument.swift in Sources */, diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index 54dae18ee..070141b87 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -23,6 +23,7 @@ struct ContentView: View { cursorPositions: [CursorPosition(line: 1, column: 1)] ) @StateObject private var suggestions: MockCompletionDelegate = MockCompletionDelegate() + @StateObject private var jumpToDefinition: MockJumpToDefinitionDelegate = MockJumpToDefinitionDelegate() @State private var font: NSFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium) @AppStorage("wrapLines") private var wrapLines: Bool = true @@ -73,7 +74,8 @@ struct ContentView: View { ) ), state: $editorState, - completionDelegate: suggestions + completionDelegate: suggestions, + jumpToDefinitionDelegate: jumpToDefinition ) .overlay(alignment: .bottom) { StatusBar( diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift index 117e7818d..5308aff1f 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift @@ -94,12 +94,13 @@ class MockCompletionDelegate: CodeSuggestionDelegate, ObservableObject { func completionWindowApplyCompletion( item: CodeSuggestionEntry, textView: TextViewController, - cursorPosition: CursorPosition + cursorPosition: CursorPosition? ) { - guard let suggestion = item as? Suggestion else { + guard let suggestion = item as? Suggestion, let cursorPosition else { return } textView.textView.undoManager?.beginUndoGrouping() + textView.textView.selectionManager.setSelectedRange(cursorPosition.range) textView.textView.insertText(suggestion.label) textView.textView.undoManager?.endUndoGrouping() } diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockJumpToDefinitionDelegate.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockJumpToDefinitionDelegate.swift new file mode 100644 index 000000000..867a939ed --- /dev/null +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockJumpToDefinitionDelegate.swift @@ -0,0 +1,34 @@ +// +// MockJumpToDefinitionDelegate.swift +// CodeEditSourceEditorExample +// +// Created by Khan Winter on 7/24/25. +// + +import AppKit +import CodeEditSourceEditor + +final class MockJumpToDefinitionDelegate: JumpToDefinitionDelegate, ObservableObject { + func queryLinks(forRange range: NSRange) async -> [JumpToDefinitionLink]? { + Bool.random() ? [ + JumpToDefinitionLink( + url: nil, + targetPosition: CursorPosition(line: 0, column: 0), + targetRange: NSRange(start: 0, end: 10), + typeName: "Start of Document", + sourcePreview: "// Comment at start" + ), + JumpToDefinitionLink( + url: URL(string: "https://codeedit.app/"), + targetPosition: CursorPosition(line: 1024, column: 10), + targetRange: NSRange(location: 30, length: 100), + typeName: "CodeEdit Website", + sourcePreview: "https://codeedit.app/" + ) + ] : nil + } + + func openLink(url: URL, targetRange: NSRange) { + NSWorkspace.shared.open(url) + } +} diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionDelegate.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionDelegate.swift index 20d1c70e0..1d80fa3d9 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionDelegate.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionDelegate.swift @@ -26,7 +26,7 @@ public protocol CodeSuggestionDelegate: AnyObject { func completionWindowApplyCompletion( item: CodeSuggestionEntry, textView: TextViewController, - cursorPosition: CursorPosition + cursorPosition: CursorPosition? ) // Optional func completionWindowDidSelect(item: CodeSuggestionEntry) diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift index 3e3705078..7f3dbaca3 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift @@ -14,9 +14,7 @@ final class SuggestionViewModel: ObservableObject { var itemsRequestTask: Task? weak var activeTextView: TextViewController? - var delegate: CodeSuggestionDelegate? { - activeTextView?.completionDelegate - } + weak var delegate: CodeSuggestionDelegate? func showCompletions( textView: TextViewController, @@ -25,11 +23,13 @@ final class SuggestionViewModel: ObservableObject { showWindowOnParent: @escaping @MainActor (NSWindow, NSRect) -> Void ) { self.activeTextView = nil + self.delegate = nil itemsRequestTask?.cancel() guard let targetParentWindow = textView.view.window else { return } self.activeTextView = textView + self.delegate = delegate itemsRequestTask = Task { do { guard let completionItems = await delegate.completionSuggestionsRequested( @@ -89,14 +89,13 @@ final class SuggestionViewModel: ObservableObject { } func applySelectedItem(item: CodeSuggestionEntry, window: NSWindow?) { - guard let activeTextView, - let cursorPosition = activeTextView.cursorPositions.first else { + guard let activeTextView else { return } self.delegate?.completionWindowApplyCompletion( item: item, textView: activeTextView, - cursorPosition: cursorPosition + cursorPosition: activeTextView.cursorPositions.first ) window?.close() } diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift index 1e70be56e..7eefddd9b 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift @@ -105,7 +105,7 @@ class SuggestionViewController: NSViewController { 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 + let newWidth = max(256, CGFloat(maxLength) * controller.font.charWidth) view.constraints.filter({ $0.firstAnchor == view.heightAnchor }).forEach { $0.isActive = false } view.heightAnchor.constraint(equalToConstant: newHeight).isActive = true diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift index 90ae4b31b..8551f4e15 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift @@ -83,7 +83,10 @@ public final class SuggestionController: NSWindowController { self.showWindow(attachedTo: parentWindow) self.constrainWindowToScreenEdges(cursorRect: cursorRect) } - (self.contentViewController as? SuggestionViewController)?.styleView(using: textView) + if let controller = self.contentViewController as? SuggestionViewController { + controller.styleView(using: textView) + self.popover?.contentSize = controller.preferredContentSize + } } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift index 7bb3ca0bf..9af6e8414 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift @@ -190,7 +190,7 @@ extension TextViewController { func setUpKeyBindings(eventMonitor: inout Any?) { eventMonitor = NSEvent.addLocalMonitorForEvents( - matching: [.keyDown, .flagsChanged, .mouseMoved] + matching: [.keyDown, .flagsChanged, .mouseMoved, .leftMouseUp] ) { [weak self] event -> NSEvent? in guard let self = self else { return event } @@ -228,6 +228,12 @@ extension TextViewController { } self.jumpToDefinitionModel?.mouseHovered(windowCoordinates: event.locationInWindow) return event + case .leftMouseUp: + if let range = jumpToDefinitionModel?.hoveredRange { + self.jumpToDefinitionModel?.performJump(at: range) + return nil + } + return event default: return event } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 627a9354d..3f662b41e 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -173,7 +173,11 @@ public class TextViewController: NSViewController { /// The tree sitter client managed by the source editor. /// /// This will be `nil` if another highlighter provider is passed to the source editor. - internal(set) public var treeSitterClient: TreeSitterClient? + internal(set) public var treeSitterClient: TreeSitterClient? { + didSet { + jumpToDefinitionModel?.treeSitterClient = treeSitterClient + } + } var foldProvider: LineFoldProvider @@ -245,13 +249,11 @@ public class TextViewController: NSViewController { } self.textCoordinators = coordinators.map { WeakCoordinator($0) } - if let treeSitterClient { - jumpToDefinitionModel = JumpToDefinitionModel( - controller: self, - treeSitterClient: treeSitterClient, - delegate: nil - ) - } + jumpToDefinitionModel = JumpToDefinitionModel( + controller: self, + treeSitterClient: treeSitterClient, + delegate: nil + ) } required init?(coder: NSCoder) { diff --git a/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift index 0673e1ed7..bc36f321b 100644 --- a/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift +++ b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift @@ -18,13 +18,13 @@ final class JumpToDefinitionModel { static let emphasisId = "jumpToDefinition" weak var delegate: JumpToDefinitionDelegate? + weak var treeSitterClient: TreeSitterClient? private weak var controller: TextViewController? - private weak var treeSitterClient: TreeSitterClient? private(set) public var hoveredRange: NSRange? - private var hoverRequestTask: Task? + private var hoverRequestTask: Task? private var jumpRequestTask: Task? private var currentLinks: [JumpToDefinitionLink]? @@ -33,7 +33,7 @@ final class JumpToDefinitionModel { controller?.textView } - init(controller: TextViewController, treeSitterClient: TreeSitterClient, delegate: JumpToDefinitionDelegate?) { + init(controller: TextViewController, treeSitterClient: TreeSitterClient?, delegate: JumpToDefinitionDelegate?) { self.controller = controller self.treeSitterClient = treeSitterClient self.delegate = delegate @@ -87,7 +87,7 @@ final class JumpToDefinitionModel { // MARK: - Link Popover - func presentLinkPopover(on range: NSRange, links: [JumpToDefinitionLink]) { + private func presentLinkPopover(on range: NSRange, links: [JumpToDefinitionLink]) { let halfway = range.location + (range.length / 2) let range = NSRange(location: halfway, length: 0) guard let controller, @@ -105,7 +105,7 @@ final class JumpToDefinitionModel { // MARK: - Local Link - func openLocalLink(link: JumpToDefinitionLink) { + private func openLocalLink(link: JumpToDefinitionLink) { guard let controller = controller else { return } controller.textView.selectionManager.setSelectedRange(link.targetRange) controller.textView.scrollSelectionToVisible() @@ -179,7 +179,7 @@ extension JumpToDefinitionModel: CodeSuggestionDelegate { func completionWindowApplyCompletion( item: CodeSuggestionEntry, textView: TextViewController, - cursorPosition: CursorPosition + cursorPosition: CursorPosition? ) { guard let link = item as? JumpToDefinitionLink else { return } if let url = link.url { diff --git a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift index 342fb1fc8..088bdc3a3 100644 --- a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift +++ b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift @@ -36,7 +36,8 @@ public struct SourceEditor: NSViewControllerRepresentable { highlightProviders: [any HighlightProviding]? = nil, undoManager: CEUndoManager? = nil, coordinators: [any TextViewCoordinator] = [], - completionDelegate: CodeSuggestionDelegate? = nil + completionDelegate: CodeSuggestionDelegate? = nil, + jumpToDefinitionDelegate: JumpToDefinitionDelegate? = nil ) { self.text = .binding(text) self.language = language @@ -46,6 +47,7 @@ public struct SourceEditor: NSViewControllerRepresentable { self.undoManager = undoManager self.coordinators = coordinators self.completionDelegate = completionDelegate + self.jumpToDefinitionDelegate = jumpToDefinitionDelegate } /// Initializes a new source editor @@ -67,7 +69,8 @@ public struct SourceEditor: NSViewControllerRepresentable { highlightProviders: [any HighlightProviding]? = nil, undoManager: CEUndoManager? = nil, coordinators: [any TextViewCoordinator] = [], - completionDelegate: CodeSuggestionDelegate? = nil + completionDelegate: CodeSuggestionDelegate? = nil, + jumpToDefinitionDelegate: JumpToDefinitionDelegate? = nil ) { self.text = .storage(text) self.language = language @@ -77,6 +80,7 @@ public struct SourceEditor: NSViewControllerRepresentable { self.undoManager = undoManager self.coordinators = coordinators self.completionDelegate = completionDelegate + self.jumpToDefinitionDelegate = jumpToDefinitionDelegate } var text: TextAPI @@ -87,6 +91,7 @@ public struct SourceEditor: NSViewControllerRepresentable { var undoManager: CEUndoManager? var coordinators: [any TextViewCoordinator] weak var completionDelegate: CodeSuggestionDelegate? + weak var jumpToDefinitionDelegate: JumpToDefinitionDelegate? public typealias NSViewControllerType = TextViewController @@ -114,6 +119,7 @@ public struct SourceEditor: NSViewControllerRepresentable { } controller.completionDelegate = completionDelegate + controller.jumpToDefinitionModel?.delegate = jumpToDefinitionDelegate context.coordinator.setController(controller) return controller @@ -125,6 +131,7 @@ public struct SourceEditor: NSViewControllerRepresentable { public func updateNSViewController(_ controller: TextViewController, context: Context) { controller.completionDelegate = completionDelegate + controller.jumpToDefinitionModel?.delegate = jumpToDefinitionDelegate context.coordinator.updateHighlightProviders(highlightProviders) From 22cb5beac0fee945efac72f55a2869db58d3e93c Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 24 Jul 2025 15:30:37 -0500 Subject: [PATCH 26/41] fix:lint --- .../Views/MockJumpToDefinitionDelegate.swift | 2 +- .../TextViewController+Lifecycle.swift | 71 ++++++++++--------- 2 files changed, 38 insertions(+), 35 deletions(-) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockJumpToDefinitionDelegate.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockJumpToDefinitionDelegate.swift index 867a939ed..62058ca7c 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockJumpToDefinitionDelegate.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockJumpToDefinitionDelegate.swift @@ -27,7 +27,7 @@ final class MockJumpToDefinitionDelegate: JumpToDefinitionDelegate, ObservableOb ) ] : nil } - + func openLink(url: URL, targetRange: NSRange) { NSWorkspace.shared.open(url) } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift index 9af6e8414..1d5d144f5 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift @@ -200,43 +200,46 @@ extension TextViewController { // Only handle commands if this is the key window and text view is first responder guard isKeyWindow && isFirstResponder else { return event } - let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + return handleEvent(event: event) + } + } - switch event.type { - case .keyDown: - let tabKey: UInt16 = 0x30 + func handleEvent(event: NSEvent) -> NSEvent? { + let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + switch event.type { + case .keyDown: + let tabKey: UInt16 = 0x30 - if event.keyCode == tabKey { - return self.handleTab(event: event, modifierFalgs: modifierFlags.rawValue) - } else { - return self.handleCommand(event: event, modifierFlags: modifierFlags.rawValue) - } - case .flagsChanged: - if modifierFlags.contains(.command), - let coords = view.window?.convertPoint(fromScreen: NSEvent.mouseLocation) { - self.jumpToDefinitionModel?.mouseHovered(windowCoordinates: coords) - } + if event.keyCode == tabKey { + return self.handleTab(event: event, modifierFlags: modifierFlags.rawValue) + } else { + return self.handleCommand(event: event, modifierFlags: modifierFlags.rawValue) + } + case .flagsChanged: + if modifierFlags.contains(.command), + let coords = view.window?.convertPoint(fromScreen: NSEvent.mouseLocation) { + self.jumpToDefinitionModel?.mouseHovered(windowCoordinates: coords) + } - if !modifierFlags.contains(.command) { - self.jumpToDefinitionModel?.cancelHover() - } - return event - case .mouseMoved: - guard modifierFlags.contains(.command) else { - self.jumpToDefinitionModel?.cancelHover() - return event - } - self.jumpToDefinitionModel?.mouseHovered(windowCoordinates: event.locationInWindow) - return event - case .leftMouseUp: - if let range = jumpToDefinitionModel?.hoveredRange { - self.jumpToDefinitionModel?.performJump(at: range) - return nil - } - return event - default: + if !modifierFlags.contains(.command) { + self.jumpToDefinitionModel?.cancelHover() + } + return event + case .mouseMoved: + guard modifierFlags.contains(.command) else { + self.jumpToDefinitionModel?.cancelHover() return event } + self.jumpToDefinitionModel?.mouseHovered(windowCoordinates: event.locationInWindow) + return event + case .leftMouseUp: + if let range = jumpToDefinitionModel?.hoveredRange { + self.jumpToDefinitionModel?.performJump(at: range) + return nil + } + return event + default: + return event } } @@ -277,10 +280,10 @@ extension TextViewController { /// are highlighted and handles indenting accordingly. /// /// - Returns: The original event if it should be passed on, or `nil` to indicate handling within the method. - func handleTab(event: NSEvent, modifierFalgs: UInt) -> NSEvent? { + func handleTab(event: NSEvent, modifierFlags: UInt) -> NSEvent? { let shiftKey = NSEvent.ModifierFlags.shift.rawValue - if modifierFalgs == shiftKey { + if modifierFlags == shiftKey { handleIndent(inwards: true) } else { // Only allow tab to work if multiple lines are selected 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 27/41] 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 28/41] 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 29/41] 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 30/41] 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) From a763fa5d7a1ba605a2139d1a5ed933ed38062543 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 28 Jul 2025 15:54:40 -0500 Subject: [PATCH 31/41] Make CursorPosition More Flexible --- Package.resolved | 13 +-- .../TextViewController+Cursor.swift | 84 ++++++++++++++----- .../JumpToDefinitionDelegate.swift | 4 +- .../JumpToDefinitionLink.swift | 12 +-- .../JumpToDefinitionModel.swift | 17 ++-- .../Utils/CursorPosition.swift | 48 +++++++---- 6 files changed, 115 insertions(+), 63 deletions(-) diff --git a/Package.resolved b/Package.resolved index 954e31f14..bbce4e893 100644 --- a/Package.resolved +++ b/Package.resolved @@ -18,15 +18,6 @@ "version" : "0.2.3" } }, - { - "identity" : "codeedittextview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", - "state" : { - "revision" : "8f02a6b206091ee4aaee9006e2ef1ddc68e754c8", - "version" : "0.11.4" - } - }, { "identity" : "rearrange", "kind" : "remoteSourceControl", @@ -41,8 +32,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/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift index f077f6b95..c157f027c 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift @@ -15,18 +15,30 @@ extension TextViewController { if isPostingCursorNotification { return } var newSelectedRanges: [NSRange] = [] for position in positions { - let line = position.line - let column = position.column - guard (line > 0 && column > 0) || (position.range != .notFound) else { continue } + guard (position.start.isPositive && position.end?.isPositive ?? true) + || (position.range != .notFound) else { + continue + } if position.range == .notFound { if textView.textStorage.length == 0 { // If the file is blank, automatically place the cursor in the first index. newSelectedRanges.append(NSRange(location: 0, length: 0)) - } else if let linePosition = textView.layoutManager.textLineForIndex(line - 1) { + } else if let linePosition = textView.layoutManager.textLineForIndex(position.start.line - 1) { // If this is a valid line, set the new position - let index = linePosition.range.lowerBound + min(linePosition.range.upperBound, column - 1) - newSelectedRanges.append(NSRange(location: index, length: 0)) + let startCharacter = linePosition.range.lowerBound + min( + linePosition.range.upperBound, + position.start.column - 1 + ) + if let end = position.end, let endLine = textView.layoutManager.textLineForIndex(end.line - 1) { + let endCharacter = endLine.range.lowerBound + min( + endLine.range.upperBound, + end.column - 1 + ) + newSelectedRanges.append(NSRange(start: startCharacter, end: endCharacter)) + } else { + newSelectedRanges.append(NSRange(location: startCharacter, length: 0)) + } } } else { newSelectedRanges.append(position.range) @@ -46,9 +58,20 @@ extension TextViewController { guard let linePosition = textView.layoutManager.textLineForOffset(selectedRange.range.location) else { continue } - let column = (selectedRange.range.location - linePosition.range.location) + 1 - let line = linePosition.index + 1 - positions.append(CursorPosition(range: selectedRange.range, line: line, column: column)) + let start = CursorPosition.Position( + line: linePosition.index + 1, + column: (selectedRange.range.location - linePosition.range.location) + 1 + ) + let end = if !selectedRange.range.isEmpty, + let endPosition = textView.layoutManager.textLineForOffset(selectedRange.range.max) { + CursorPosition.Position( + line: endPosition.index + 1, + column: selectedRange.range.max - endPosition.range.location + 1 + ) + } else { + CursorPosition.Position?.none + } + positions.append(CursorPosition(range: selectedRange.range, start: start, end: end)) } isPostingCursorNotification = true @@ -66,26 +89,43 @@ extension TextViewController { /// 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? { + public 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 { + guard position.start.line > 0, position.start.column > 0, + let linePosition = textView.layoutManager.textLineForIndex(position.start.line - 1) else { return nil } - range = NSRange(location: linePosition.range.location + position.column, length: 0) + if let end = position.end, let endPosition = textView.layoutManager.textLineForIndex(end.line - 1) { + range = NSRange( + location: linePosition.range.location + position.start.column, + length: linePosition.range.max + ) + } else { + range = NSRange(location: linePosition.range.location + position.start.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 + var start: CursorPosition.Position + var end: CursorPosition.Position? + + guard let startLinePosition = textView.layoutManager.textLineForOffset(range.location) else { + return nil + } + + start = CursorPosition.Position( + line: startLinePosition.index + 1, + column: (range.location - startLinePosition.range.location) + 1 + ) + + if !range.isEmpty { + guard let endLinePosition = textView.layoutManager.textLineForOffset(range.max) else { return nil } + end = CursorPosition.Position( + line: endLinePosition.index + 1, + column: (range.max - endLinePosition.range.location) + 1 + ) } - return CursorPosition(range: range, line: line, column: column) + return CursorPosition(range: range, start: start, end: end) } } diff --git a/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionDelegate.swift b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionDelegate.swift index 5951adf9a..800714d28 100644 --- a/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionDelegate.swift +++ b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionDelegate.swift @@ -8,6 +8,6 @@ import Foundation public protocol JumpToDefinitionDelegate: AnyObject { - func queryLinks(forRange range: NSRange) async -> [JumpToDefinitionLink]? - func openLink(url: URL, targetRange: NSRange) + func queryLinks(forRange range: NSRange, textView: TextViewController) async -> [JumpToDefinitionLink]? + func openLink(link: JumpToDefinitionLink) } diff --git a/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLink.swift b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLink.swift index e228cc104..8b3e5d317 100644 --- a/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLink.swift +++ b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLink.swift @@ -12,8 +12,10 @@ public struct JumpToDefinitionLink: Identifiable, Sendable, CodeSuggestionEntry public var id: String { url?.absoluteString ?? "\(targetRange)" } /// Leave as `nil` if the link is in the same document. public let url: URL? - public let targetPosition: CursorPosition? - public let targetRange: NSRange + public var targetPosition: CursorPosition? { + targetRange + } + public let targetRange: CursorPosition public let label: String public let sourcePreview: String? @@ -21,21 +23,19 @@ public struct JumpToDefinitionLink: Identifiable, Sendable, CodeSuggestionEntry public let image: Image public let imageColor: Color - public var detail: String? { nil } + public var detail: String? { url?.lastPathComponent } public var pathComponents: [String]? { url?.pathComponents ?? [] } public var deprecated: Bool { false } public init( url: URL?, - targetPosition: CursorPosition, - targetRange: NSRange, + targetRange: CursorPosition, typeName: String, sourcePreview: String, image: Image = Image(systemName: "dot.square.fill"), imageColor: Color = Color(NSColor.lightGray) ) { self.url = url - self.targetPosition = targetPosition self.targetRange = targetRange self.label = typeName self.sourcePreview = sourcePreview diff --git a/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift index bc36f321b..d25f9f1f1 100644 --- a/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift +++ b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift @@ -62,7 +62,8 @@ final class JumpToDefinitionModel { jumpRequestTask?.cancel() jumpRequestTask = Task { currentLinks = nil - guard let links = await delegate?.queryLinks(forRange: location), + guard let controller, + let links = await delegate?.queryLinks(forRange: location, textView: controller), !links.isEmpty else { NSSound.beep() if let textView { @@ -72,10 +73,10 @@ final class JumpToDefinitionModel { } if links.count == 1 { let link = links[0] - if let url = link.url { - delegate?.openLink(url: url, targetRange: link.targetRange) + if link.url != nil { + delegate?.openLink(link: link) } else { - textView?.selectionManager.setSelectedRange(link.targetRange) + textView?.selectionManager.setSelectedRange(link.targetRange.range) } textView?.scrollSelectionToVisible() @@ -106,8 +107,10 @@ final class JumpToDefinitionModel { // MARK: - Local Link private func openLocalLink(link: JumpToDefinitionLink) { - guard let controller = controller else { return } - controller.textView.selectionManager.setSelectedRange(link.targetRange) + guard let controller = controller, let range = controller.resolveCursorPosition(link.targetRange) else { + return + } + controller.textView.selectionManager.setSelectedRange(range.range) controller.textView.scrollSelectionToVisible() } @@ -183,7 +186,7 @@ extension JumpToDefinitionModel: CodeSuggestionDelegate { ) { guard let link = item as? JumpToDefinitionLink else { return } if let url = link.url { - delegate?.openLink(url: url, targetRange: link.targetRange) + delegate?.openLink(link: link) } else { openLocalLink(link: link) } diff --git a/Sources/CodeEditSourceEditor/Utils/CursorPosition.swift b/Sources/CodeEditSourceEditor/Utils/CursorPosition.swift index 508684527..8d4f798a0 100644 --- a/Sources/CodeEditSourceEditor/Utils/CursorPosition.swift +++ b/Sources/CodeEditSourceEditor/Utils/CursorPosition.swift @@ -17,6 +17,22 @@ import Foundation /// controller. /// public struct CursorPosition: Sendable, Codable, Equatable, Hashable { + public struct Position: Sendable, Codable, Equatable, Hashable { + /// The line the cursor is located at. 1-indexed. + /// If ``CursorPosition/range`` is not empty, this is the line at the beginning of the selection. + public let line: Int + /// The column the cursor is located at. 1-indexed. + /// If ``CursorPosition/range`` is not empty, this is the column at the beginning of the selection. + public let column: Int + + public init(line: Int, column: Int) { + self.line = line + self.column = column + } + + var isPositive: Bool { line > 0 && column > 0 } + } + /// Initialize a cursor position. /// /// When this initializer is used, ``CursorPosition/range`` will be initialized to `NSNotFound`. @@ -28,8 +44,14 @@ public struct CursorPosition: Sendable, Codable, Equatable, Hashable { /// - column: The column of the cursor position, 1-indexed. public init(line: Int, column: Int) { self.range = .notFound - self.line = line - self.column = column + self.start = Position(line: line, column: column) + self.end = nil + } + + public init(start: Position, end: Position) { + self.range = .notFound + self.start = start + self.end = end } /// Initialize a cursor position. @@ -41,27 +63,23 @@ public struct CursorPosition: Sendable, Codable, Equatable, Hashable { /// - Parameter range: The range of the cursor position. public init(range: NSRange) { self.range = range - self.line = -1 - self.column = -1 + self.start = Position(line: -1, column: -1) + self.end = nil } /// Private initializer. /// - Parameters: /// - range: The range of the position. - /// - line: The line of the position. - /// - column: The column of the position. - package init(range: NSRange, line: Int, column: Int) { + /// - start: The start position of the range. + /// - end: The end position of the range. + package init(range: NSRange, start: Position, end: Position?) { self.range = range - self.line = line - self.column = column + self.start = start + self.end = end } /// The range of the selection. public let range: NSRange - /// The line the cursor is located at. 1-indexed. - /// If ``CursorPosition/range`` is not empty, this is the line at the beginning of the selection. - public let line: Int - /// The column the cursor is located at. 1-indexed. - /// If ``CursorPosition/range`` is not empty, this is the column at the beginning of the selection. - public let column: Int + public let start: Position + public let end: Position? } From 3be125bf67dd532ca65d86e9c4d8087dfdfac492 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 28 Jul 2025 15:55:06 -0500 Subject: [PATCH 32/41] Adjust Style Container Length When Setting Providers --- .../Highlighting/Highlighter.swift | 1 + .../StyledRangeContainer.swift | 19 +++++++++++++++++++ .../RangeStore/RangeStore.swift | 2 +- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift index 9f1e70ea4..cefd82bc3 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift @@ -154,6 +154,7 @@ class Highlighter: NSObject { /// - Parameter providers: All providers to use. public func setProviders(_ providers: [HighlightProviding]) { guard let textView else { return } + self.styleContainer.updateStorageLength(newLength: textView.textStorage.length) let existingIds: [ObjectIdentifier] = self.highlightProviders .compactMap { $0.highlightProvider } diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift index da1966b31..c77e2b58f 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift @@ -111,6 +111,25 @@ class StyledRangeContainer { } extension StyledRangeContainer: HighlightProviderStateDelegate { + func updateStorageLength(newLength: Int) { + for key in _storage.keys { + guard var value = _storage[key] else { continue } + var store = value.store + let length = store.length + if length != newLength { + let missingCharacters = newLength - length + if missingCharacters < 0 { + store.storageUpdated(replacedCharactersIn: (length + missingCharacters)..: Sendable { /// - runs: The runs to insert. /// - range: The range to replace. mutating func set(runs: [Run], for range: Range) { - let gutsRange = 0..<_guts.count(in: OffsetMetric()) + let gutsRange = 0.. Date: Tue, 29 Jul 2025 14:21:40 -0500 Subject: [PATCH 33/41] Preview View, Tons Of Completion Window Stability Fixes --- .../Views/MockCompletionDelegate.swift | 18 ++- .../Views/MockJumpToDefinitionDelegate.swift | 18 +-- .../Views/StatusBar.swift | 2 +- .../TableView/CodeSuggestionPreviewView.swift | 119 +++++++++++++++ .../TableView/SuggestionViewController.swift | 140 +++++++++++++++--- .../Window/SuggestionController+Window.swift | 5 + .../Window/SuggestionController.swift | 103 +++++-------- .../JumpToDefinitionModel.swift | 4 +- .../Utils/CursorPosition.swift | 2 +- 9 files changed, 308 insertions(+), 103 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionPreviewView.swift diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift index 5867a3c65..509e635c1 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift @@ -48,15 +48,18 @@ class MockCompletionDelegate: CodeSuggestionDelegate, ObservableObject { class Suggestion: CodeSuggestionEntry { var label: String var detail: String? - var pathComponents: [String]? { nil } + var pathComponents: [String]? var targetPosition: CursorPosition? { nil } - var sourcePreview: String? { nil } + var sourcePreview: String? var image: Image = Image(systemName: "dot.square.fill") var imageColor: Color = .gray var deprecated: Bool = false - init(text: String) { + init(text: String, detail: String?, sourcePreview: String?, pathComponents: [String]?) { self.label = text + self.detail = detail + self.sourcePreview = sourcePreview + self.pathComponents = pathComponents } } @@ -67,7 +70,14 @@ class MockCompletionDelegate: CodeSuggestionDelegate, ObservableObject { let randomString = (0.. [JumpToDefinitionLink]? { - Bool.random() ? [ + func queryLinks(forRange range: NSRange, textView: TextViewController) async -> [JumpToDefinitionLink]? { + [ JumpToDefinitionLink( url: nil, - targetPosition: CursorPosition(line: 0, column: 0), - targetRange: NSRange(start: 0, end: 10), + targetRange: CursorPosition(line: 0, column: 10), typeName: "Start of Document", sourcePreview: "// Comment at start" ), JumpToDefinitionLink( url: URL(string: "https://codeedit.app/"), - targetPosition: CursorPosition(line: 1024, column: 10), - targetRange: NSRange(location: 30, length: 100), + targetRange: CursorPosition(line: 1024, column: 10), typeName: "CodeEdit Website", sourcePreview: "https://codeedit.app/" ) - ] : nil + ] } - func openLink(url: URL, targetRange: NSRange) { - NSWorkspace.shared.open(url) + func openLink(link: JumpToDefinitionLink) { + if let url = link.url { + NSWorkspace.shared.open(url) + } } } diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift index a4b5c66f4..72590a575 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift @@ -211,6 +211,6 @@ struct StatusBar: View { } // When there's a single cursor, display the line and column. - return "Line: \(cursorPositions[0].line) Col: \(cursorPositions[0].column) Range: \(cursorPositions[0].range)" + return "Line: \(cursorPositions[0].start.line) Col: \(cursorPositions[0].start.column) Range: \(cursorPositions[0].range)" } } diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionPreviewView.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionPreviewView.swift new file mode 100644 index 000000000..2cc59c428 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionPreviewView.swift @@ -0,0 +1,119 @@ +// +// CodeSuggestionPreviewView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 7/28/25. +// + +import SwiftUI + +final class CodeSuggestionPreviewView: NSVisualEffectView { + private let spacing: CGFloat = 5 + + var sourcePreview: String? { + didSet { + sourcePreviewLabel.stringValue = sourcePreview ?? "" + sourcePreviewLabel.isHidden = sourcePreview == nil + } + } + + var documentation: String? { + didSet { + documentationLabel.stringValue = documentation ?? "" + documentationLabel.isHidden = documentation == nil + } + } + + var pathComponents: [String] = [] { + didSet { + configurePathComponentsLabel() + } + } + + var targetRange: CursorPosition? { + didSet { + configurePathComponentsLabel() + } + } + + var font: NSFont = .systemFont(ofSize: 12) { + didSet { + sourcePreviewLabel.font = font + pathComponentsLabel.font = font + } + } + var documentationFont: NSFont = .systemFont(ofSize: 12) { + didSet { + documentationLabel.font = documentationFont + } + } + + var stackView: NSStackView = NSStackView() + var dividerView: NSView = NSView() + var sourcePreviewLabel: NSTextField = NSTextField() + var documentationLabel: NSTextField = NSTextField() + var pathComponentsLabel: NSTextField = NSTextField() + + init() { + super.init(frame: .zero) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = spacing + stackView.orientation = .vertical + stackView.alignment = .leading + stackView.setContentCompressionResistancePriority(.required, for: .vertical) + stackView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + addSubview(stackView) + + dividerView.translatesAutoresizingMaskIntoConstraints = false + dividerView.wantsLayer = true + dividerView.layer?.backgroundColor = NSColor.separatorColor.cgColor + addSubview(dividerView) + + self.material = .windowBackground + self.blendingMode = .behindWindow + + styleStaticLabel(sourcePreviewLabel) + styleStaticLabel(documentationLabel) + styleStaticLabel(pathComponentsLabel) + + stackView.addArrangedSubview(sourcePreviewLabel) + stackView.addArrangedSubview(documentationLabel) + stackView.addArrangedSubview(pathComponentsLabel) + + NSLayoutConstraint.activate([ + dividerView.topAnchor.constraint(equalTo: topAnchor), + dividerView.leadingAnchor.constraint(equalTo: leadingAnchor), + dividerView.trailingAnchor.constraint(equalTo: trailingAnchor), + dividerView.heightAnchor.constraint(equalToConstant: 1), + + stackView.topAnchor.constraint(equalTo: dividerView.bottomAnchor, constant: spacing), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -SuggestionController.WINDOW_PADDING), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 13), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -13) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setPreferredMaxLayoutWidth(width: CGFloat) { + sourcePreviewLabel.preferredMaxLayoutWidth = width + documentationLabel.preferredMaxLayoutWidth = width + pathComponentsLabel.preferredMaxLayoutWidth = width + } + + private func styleStaticLabel(_ label: NSTextField) { + label.isEditable = false + label.allowsDefaultTighteningForTruncation = false + label.isBezeled = false + label.isBordered = false + label.backgroundColor = .clear + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + } + + private func configurePathComponentsLabel() { + // TODO: This + pathComponentsLabel.isHidden = true + } +} diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift index c9b4524b8..0fe0fa10d 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift @@ -10,12 +10,18 @@ import SwiftUI import Combine class SuggestionViewController: NSViewController { - var tintView: NSView! - var tableView: NSTableView! - var scrollView: NSScrollView! - var noItemsLabel: NSTextField! + var tintView: NSView = NSView() + var tableView: NSTableView = NSTableView() + var scrollView: NSScrollView = NSScrollView() + var noItemsLabel: NSTextField = NSTextField(labelWithString: "No Completions") + var previewView: CodeSuggestionPreviewView = CodeSuggestionPreviewView() + + var scrollViewHeightConstraint: NSLayoutConstraint? + var viewHeightConstraint: NSLayoutConstraint? + var viewWidthConstraint: NSLayoutConstraint? var itemObserver: AnyCancellable? + var cachedFont: NSFont? weak var model: SuggestionViewModel? { didSet { @@ -26,32 +32,36 @@ class SuggestionViewController: NSViewController { } } + /// An event monitor for keyboard events + private var localEventMonitor: Any? + + weak var windowController: SuggestionController? + 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 tintView.layer?.backgroundColor = .clear 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 + previewView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(noItemsLabel) view.addSubview(scrollView) + view.addSubview(previewView) NSLayoutConstraint.activate([ tintView.topAnchor.constraint(equalTo: view.topAnchor), @@ -66,7 +76,11 @@ class SuggestionViewController: NSViewController { scrollView.topAnchor.constraint(equalTo: view.topAnchor), scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + scrollView.bottomAnchor.constraint(equalTo: previewView.topAnchor), + + previewView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + previewView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + previewView.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) } @@ -77,10 +91,59 @@ class SuggestionViewController: NSViewController { if let controller = model?.activeTextView { styleView(using: controller) } + setupEventMonitors() + } + + override func viewWillDisappear() { + super.viewWillDisappear() + if let monitor = localEventMonitor { + NSEvent.removeMonitor(monitor) + localEventMonitor = nil + } + } + + private func setupEventMonitors() { + if let monitor = localEventMonitor { + NSEvent.removeMonitor(monitor) + localEventMonitor = nil + } + localEventMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.keyDown] + ) { [weak self] event in + guard let self = self else { return event } + + switch event.type { + case .keyDown: + return checkKeyDownEvents(event) + default: + return event + } + } + } + + private func checkKeyDownEvents(_ event: NSEvent) -> NSEvent? { + switch event.keyCode { + case 53: // Escape + windowController?.close() + return nil + + case 125, 126: // Down/Up Arrow + tableView.keyDown(with: event) + return nil + + case 36, 48: // Return/Tab + self.applySelectedItem() + return nil + + default: + return event + } } func styleView(using controller: TextViewController) { noItemsLabel.font = controller.font + previewView.font = controller.font + previewView.documentationFont = controller.font switch controller.systemAppearance { case .aqua: let color = controller.theme.background @@ -103,32 +166,56 @@ class SuggestionViewController: NSViewController { updateSize(using: controller) } - func updateSize(using controller: TextViewController) { - guard model?.items.isEmpty == false else { + func updateSize(using controller: TextViewController?) { + guard model?.items.isEmpty == false && tableView.numberOfRows > 0 else { let size = NSSize(width: 256, height: noItemsLabel.fittingSize.height + 20) preferredContentSize = size - (self.view.window?.windowController as? SuggestionController)?.updateWindowSize(newSize: size) + windowController?.updateWindowSize(newSize: size) return } + + if controller != nil { + cachedFont = controller?.font + } + 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.reduce(0, { max($0, $1.label.count + ($1.detail?.count ?? 0)) }) ?? 16) + 4, 64 ) - let newWidth = CGFloat(maxLength) * controller.font.charWidth + let newWidth = max( // minimum width = 256px, horizontal item padding = 13px + CGFloat(maxLength) * (controller?.font ?? cachedFont ?? NSFont.systemFont(ofSize: 12)).charWidth + 26, + 256 + ) - view.constraints.filter({ $0.firstAnchor == view.heightAnchor }).forEach { $0.isActive = false } - view.heightAnchor.constraint(equalToConstant: newHeight).isActive = true + let rowHeight = rowView.fittingSize.height + + let numberOfVisibleRows = min(CGFloat(model?.items.count ?? 0), SuggestionController.MAX_VISIBLE_ROWS) + previewView.setPreferredMaxLayoutWidth(width: newWidth) + var newHeight = rowHeight * numberOfVisibleRows + SuggestionController.WINDOW_PADDING * 2 + + viewHeightConstraint?.isActive = false + viewWidthConstraint?.isActive = false + scrollViewHeightConstraint?.isActive = false + + scrollViewHeightConstraint = scrollView.heightAnchor.constraint(equalToConstant: newHeight) + newHeight += previewView.fittingSize.height + viewHeightConstraint = view.heightAnchor.constraint(equalToConstant: newHeight) + viewWidthConstraint = view.widthAnchor.constraint(equalToConstant: newWidth) + + viewHeightConstraint?.isActive = true + viewWidthConstraint?.isActive = true + scrollViewHeightConstraint?.isActive = true + + view.updateConstraintsForSubtreeIfNeeded() + view.layoutSubtreeIfNeeded() let newSize = NSSize(width: newWidth, height: newHeight) - (self.view.window?.windowController as? SuggestionController)?.updateWindowSize(newSize: newSize) + preferredContentSize = newSize + windowController?.updateWindowSize(newSize: newSize) } func configureTableView() { @@ -171,6 +258,7 @@ class SuggestionViewController: NSViewController { if let model { noItemsLabel.isHidden = !model.items.isEmpty scrollView.isHidden = model.items.isEmpty + previewView.isHidden = model.items.isEmpty } tableView.reloadData() if let activeTextView = model?.activeTextView { @@ -242,7 +330,17 @@ extension SuggestionViewController: NSTableViewDataSource, NSTableViewDelegate { public func tableViewSelectionDidChange(_ notification: Notification) { guard tableView.selectedRow >= 0 else { return } if let model { - model.didSelect(item: model.items[tableView.selectedRow]) + // Update our preview view + let selectedItem = model.items[tableView.selectedRow] + + previewView.sourcePreview = selectedItem.sourcePreview + // TODO: Add Documentation To Completion Items + previewView.documentation = selectedItem.detail + previewView.pathComponents = selectedItem.pathComponents ?? [] + previewView.targetRange = selectedItem.targetPosition + updateSize(using: nil) + + model.didSelect(item: selectedItem) } } } diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift index f54e103ae..66a321888 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift @@ -60,6 +60,11 @@ extension SuggestionController { } func updateWindowSize(newSize: NSSize) { + if let popover { + popover.contentSize = newSize + return + } + guard let window else { return } let oldFrame = window.frame diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift index 8551f4e15..8dd1a5b42 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift @@ -16,7 +16,7 @@ public final class SuggestionController: NSWindowController { /// Whether the suggestion window is visible var isVisible: Bool { - window?.isVisible ?? false + window?.isVisible ?? false || popover?.isShown ?? false } var model: SuggestionViewModel = SuggestionViewModel() @@ -31,10 +31,8 @@ public final class SuggestionController: NSWindowController { /// Tracks when the window is placed above the cursor var isWindowAboveCursor = false - private var popover: NSPopover? + var popover: NSPopover? - /// An event monitor for keyboard events - private var localEventMonitor: Any? /// Holds the observer for the window resign notifications private var windowResignObserver: NSObjectProtocol? @@ -49,6 +47,8 @@ public final class SuggestionController: NSWindowController { super.init(window: window) + controller.windowController = self + if window.isVisible { window.close() } @@ -72,20 +72,30 @@ public final class SuggestionController: NSWindowController { cursorPosition: cursorPosition ) { parentWindow, cursorRect in if asPopover { + self.popover?.close() + self.popover = nil + let windowPosition = parentWindow.convertFromScreen(cursorRect) let textViewPosition = textView.textView.convert(windowPosition, from: nil) let popover = NSPopover() popover.behavior = .transient - popover.contentViewController = self.contentViewController + + let controller = SuggestionViewController() + controller.model = self.model + controller.windowController = self + controller.tableView.reloadData() + controller.styleView(using: textView) + + popover.contentViewController = controller popover.show(relativeTo: textViewPosition, of: textView.textView, preferredEdge: .maxY) self.popover = popover } else { self.showWindow(attachedTo: parentWindow) self.constrainWindowToScreenEdges(cursorRect: cursorRect) - } - if let controller = self.contentViewController as? SuggestionViewController { - controller.styleView(using: textView) - self.popover?.contentSize = controller.preferredContentSize + + if let controller = self.contentViewController as? SuggestionViewController { + controller.styleView(using: textView) + } } } } @@ -108,7 +118,6 @@ public final class SuggestionController: NSWindowController { self?.close() } - setupEventMonitors() super.showWindow(nil) window.orderFront(nil) window.contentViewController?.viewWillAppear() @@ -117,63 +126,15 @@ public final class SuggestionController: NSWindowController { /// Close the window public override func close() { model.willClose() - removeEventMonitors() - popover?.close() - popover = nil - - super.close() - } - - // MARK: - Events - - private func setupEventMonitors() { - localEventMonitor = NSEvent.addLocalMonitorForEvents( - matching: [.keyDown] - ) { [weak self] event in - guard let self = self else { return event } - - switch event.type { - case .keyDown: - return checkKeyDownEvents(event) - default: - return event - } + if popover != nil { + popover?.close() + popover = nil + } else { + contentViewController?.viewWillDisappear() } - } - 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 - (contentViewController as? SuggestionViewController)?.tableView?.keyDown(with: event) - return nil - - case 36, 48: // Return/Tab - (contentViewController as? SuggestionViewController)?.applySelectedItem() - return nil - - default: - return event - } - } - - private func removeEventMonitors() { - if let monitor = localEventMonitor { - NSEvent.removeMonitor(monitor) - localEventMonitor = nil - } - if let observer = windowResignObserver { - NotificationCenter.default.removeObserver(observer) - windowResignObserver = nil - } + super.close() } // MARK: - Cursors Updated @@ -182,13 +143,23 @@ public final class SuggestionController: NSWindowController { textView: TextViewController, delegate: CodeSuggestionDelegate, position: CursorPosition, - presentIfNot: Bool = false + presentIfNot: Bool = false, + asPopover: Bool = false ) { + if !asPopover && popover != nil { + close() + } + model.cursorsUpdated(textView: textView, delegate: delegate, position: position) { close() if presentIfNot { - self.showCompletions(textView: textView, delegate: delegate, cursorPosition: position) + self.showCompletions( + textView: textView, + delegate: delegate, + cursorPosition: position, + asPopover: asPopover + ) } } } diff --git a/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift index d25f9f1f1..55a31fab2 100644 --- a/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift +++ b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift @@ -83,6 +83,8 @@ final class JumpToDefinitionModel { } else { presentLinkPopover(on: location, links: links) } + + cancelHover() } } @@ -185,7 +187,7 @@ extension JumpToDefinitionModel: CodeSuggestionDelegate { cursorPosition: CursorPosition? ) { guard let link = item as? JumpToDefinitionLink else { return } - if let url = link.url { + if link.url != nil { delegate?.openLink(link: link) } else { openLocalLink(link: link) diff --git a/Sources/CodeEditSourceEditor/Utils/CursorPosition.swift b/Sources/CodeEditSourceEditor/Utils/CursorPosition.swift index 8d4f798a0..23f260fbc 100644 --- a/Sources/CodeEditSourceEditor/Utils/CursorPosition.swift +++ b/Sources/CodeEditSourceEditor/Utils/CursorPosition.swift @@ -48,7 +48,7 @@ public struct CursorPosition: Sendable, Codable, Equatable, Hashable { self.end = nil } - public init(start: Position, end: Position) { + public init(start: Position, end: Position?) { self.range = .notFound self.start = start self.end = end From ba3b3e9d19e9185ccd4211bc91c608a61601a27a Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 29 Jul 2025 14:22:33 -0500 Subject: [PATCH 34/41] fix:lint --- .../CodeEditSourceEditorExample/Views/StatusBar.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift index 72590a575..1d101bb01 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift @@ -211,6 +211,7 @@ struct StatusBar: View { } // When there's a single cursor, display the line and column. + // swiftlint:disable:next line_length return "Line: \(cursorPositions[0].start.line) Col: \(cursorPositions[0].start.column) Range: \(cursorPositions[0].range)" } } From 066857ac44b4fcaa46660d258b7699d94090ca88 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 29 Jul 2025 14:23:07 -0500 Subject: [PATCH 35/41] fix:lint --- .../CodeSuggestion/TableView/CodeSuggestionPreviewView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionPreviewView.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionPreviewView.swift index 2cc59c428..11a18f995 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionPreviewView.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionPreviewView.swift @@ -92,7 +92,7 @@ final class CodeSuggestionPreviewView: NSVisualEffectView { stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -13) ]) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } From 19e484ac73b000e18f069e327ac508ed987c261c Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 29 Jul 2025 15:45:47 -0500 Subject: [PATCH 36/41] Add `documentation` to `CodeSuggestionEntry` --- .../Views/MockCompletionDelegate.swift | 4 +- .../Model/CodeSuggestionEntry.swift | 1 + .../TableView/CodeSuggestionPreviewView.swift | 56 ++++++++++++++++++- .../TableView/SuggestionViewController.swift | 4 +- .../JumpToDefinitionLink.swift | 9 ++- 5 files changed, 64 insertions(+), 10 deletions(-) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift index 509e635c1..4a139b3b5 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift @@ -49,7 +49,7 @@ class MockCompletionDelegate: CodeSuggestionDelegate, ObservableObject { var label: String var detail: String? var pathComponents: [String]? - var targetPosition: CursorPosition? { nil } + var targetPosition: CursorPosition? = CursorPosition(line: 10, column: 20) var sourcePreview: String? var image: Image = Image(systemName: "dot.square.fill") var imageColor: Color = .gray @@ -75,7 +75,7 @@ class MockCompletionDelegate: CodeSuggestionDelegate, ObservableObject { text: randomString, detail: text.randomElement()!, sourcePreview: randomString, - pathComponents: (0.. 1 { + string.append(NSAttributedString(string: ":\(targetRange.start.column)")) + } + } + if let paragraphStyle = NSMutableParagraphStyle.default.mutableCopy() as? NSMutableParagraphStyle { + paragraphStyle.lineBreakMode = .byTruncatingMiddle + string.addAttribute( + .paragraphStyle, + value: paragraphStyle, + range: NSRange(location: 0, length: string.length) + ) + } + + pathComponentsLabel.attributedStringValue = string } } diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift index 0fe0fa10d..d925659e5 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift @@ -334,10 +334,10 @@ extension SuggestionViewController: NSTableViewDataSource, NSTableViewDelegate { let selectedItem = model.items[tableView.selectedRow] previewView.sourcePreview = selectedItem.sourcePreview - // TODO: Add Documentation To Completion Items - previewView.documentation = selectedItem.detail + previewView.documentation = selectedItem.documentation previewView.pathComponents = selectedItem.pathComponents ?? [] previewView.targetRange = selectedItem.targetPosition + previewView.hideIfEmpty() updateSize(using: nil) model.didSelect(item: selectedItem) diff --git a/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLink.swift b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLink.swift index 8b3e5d317..0c4bec240 100644 --- a/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLink.swift +++ b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLink.swift @@ -18,13 +18,14 @@ public struct JumpToDefinitionLink: Identifiable, Sendable, CodeSuggestionEntry public let targetRange: CursorPosition public let label: String - public let sourcePreview: String? + public var detail: String? { url?.lastPathComponent } + public var documentation: String? + public let sourcePreview: String? public let image: Image public let imageColor: Color - public var detail: String? { url?.lastPathComponent } - public var pathComponents: [String]? { url?.pathComponents ?? [] } + public var pathComponents: [String]? { url?.relativePath.components(separatedBy: "/") ?? [] } public var deprecated: Bool { false } public init( @@ -32,12 +33,14 @@ public struct JumpToDefinitionLink: Identifiable, Sendable, CodeSuggestionEntry targetRange: CursorPosition, typeName: String, sourcePreview: String, + documentation: String?, image: Image = Image(systemName: "dot.square.fill"), imageColor: Color = Color(NSColor.lightGray) ) { self.url = url self.targetRange = targetRange self.label = typeName + self.documentation = documentation self.sourcePreview = sourcePreview self.image = image self.imageColor = imageColor From ccc19f52a51c60ffbb3ed175f1c6bae7ea0557fc Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 29 Jul 2025 16:09:47 -0500 Subject: [PATCH 37/41] Add Syntax Highlighting To Source Previews --- .../Model/SuggestionViewModel.swift | 25 +++++++ .../TableView/CodeSuggestionPreviewView.swift | 4 +- .../TableView/SuggestionViewController.swift | 2 +- .../TreeSitterClient+Temporary.swift | 65 +++++++++++++++++-- 4 files changed, 89 insertions(+), 7 deletions(-) diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift index 49a4ad4e7..2b66bf3ae 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift @@ -16,6 +16,8 @@ final class SuggestionViewModel: ObservableObject { weak var delegate: CodeSuggestionDelegate? + private var syntaxHighlightedCache: [Int: NSAttributedString] = [:] + func showCompletions( textView: TextViewController, delegate: CodeSuggestionDelegate, @@ -56,6 +58,7 @@ final class SuggestionViewModel: ObservableObject { } self.items = completionItems.items + self.syntaxHighlightedCache = [:] showWindowOnParent(targetParentWindow, cursorRect) } } catch { @@ -109,4 +112,26 @@ final class SuggestionViewModel: ObservableObject { items.removeAll() activeTextView = nil } + + func syntaxHighlights(forIndex index: Int) -> NSAttributedString? { + if let cached = syntaxHighlightedCache[index] { + return cached + } + + if let sourcePreview = items[index].sourcePreview, + let theme = activeTextView?.theme, + let font = activeTextView?.font, + let language = activeTextView?.language { + let string = TreeSitterClient.quickHighlight( + string: sourcePreview, + theme: theme, + font: font, + language: language + ) + syntaxHighlightedCache[index] = string + return string + } + + return nil + } } diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionPreviewView.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionPreviewView.swift index e7acb8b3a..bcf0f4fae 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionPreviewView.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionPreviewView.swift @@ -10,9 +10,9 @@ import SwiftUI final class CodeSuggestionPreviewView: NSVisualEffectView { private let spacing: CGFloat = 5 - var sourcePreview: String? { + var sourcePreview: NSAttributedString? { didSet { - sourcePreviewLabel.stringValue = sourcePreview ?? "" + sourcePreviewLabel.attributedStringValue = sourcePreview ?? NSAttributedString(string: "") sourcePreviewLabel.isHidden = sourcePreview == nil } } diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift index d925659e5..c9b576101 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift @@ -333,7 +333,7 @@ extension SuggestionViewController: NSTableViewDataSource, NSTableViewDelegate { // Update our preview view let selectedItem = model.items[tableView.selectedRow] - previewView.sourcePreview = selectedItem.sourcePreview + previewView.sourcePreview = model.syntaxHighlights(forIndex: tableView.selectedRow) previewView.documentation = selectedItem.documentation previewView.pathComponents = selectedItem.pathComponents ?? [] previewView.targetRange = selectedItem.targetPosition diff --git a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Temporary.swift b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Temporary.swift index 22dfe564b..5bc271fb6 100644 --- a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Temporary.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Temporary.swift @@ -5,10 +5,67 @@ // Created by Khan Winter on 7/24/25. // -import Foundation +import AppKit +import SwiftTreeSitter +import CodeEditLanguages extension TreeSitterClient { -// func temporaryHighlight() -> NSAttributedString { -// -// } + static func quickHighlight( + string: String, + theme: EditorTheme, + font: NSFont, + language: CodeLanguage + ) -> NSAttributedString? { + guard let parserLanguage = language.language, let query = TreeSitterModel.shared.query(for: language.id) else { + return nil + } + + do { + let parser = Parser() + try parser.setLanguage(parserLanguage) + guard let syntaxTree = parser.parse(string) else { + return nil + } + let queryCursor = query.execute(in: syntaxTree) + var ranges: [NSRange: Int] = [:] + let highlights: [HighlightRange] = queryCursor + .resolve(with: .init(string: string)) + .flatMap { $0.captures } + .reversed() // SwiftTreeSitter returns captures in the reverse order of what we need to filter with. + .compactMap { capture in + let range = capture.range + let index = capture.index + + // Lower indexed captures are favored over higher, this is why we reverse it above + if let existingLevel = ranges[range], existingLevel <= index { + return nil + } + + guard let captureName = CaptureName.fromString(capture.name) else { + return nil + } + + // Update the filter level to the current index since it's lower and a 'valid' capture + ranges[range] = index + + return HighlightRange(range: range, capture: captureName) + } + + var string = NSMutableAttributedString(string: string) + + for highlight in highlights { + string.setAttributes( + [ + .font: theme.fontFor(for: highlight.capture, from: font), + .foregroundColor: theme.colorFor(highlight.capture) + ], + range: highlight.range + ) + } + + return string + } catch { + return nil + } + } } From 2026e68c9609f0ee40bd61540f4cb46d1ac0ead0 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 30 Jul 2025 12:58:40 -0500 Subject: [PATCH 38/41] Update Dependencies --- Package.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Package.swift b/Package.swift index 129e212c1..da9e3832c 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.12.0" - path: "../CodeEditTextView" + url: "https://github.com/CodeEditApp/CodeEditTextView.git", + from: "0.12.1" ), // tree-sitter languages .package( From bd3a8289459a33983dfbf34287d85dba8e1808fb Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 30 Jul 2025 13:05:44 -0500 Subject: [PATCH 39/41] fix:tests --- Package.resolved | 9 +++++ .../Utils/CursorPosition.swift | 2 +- .../Controller/TextViewControllerTests.swift | 36 +++++++++---------- .../TagEditingTests.swift | 4 +-- 4 files changed, 30 insertions(+), 21 deletions(-) diff --git a/Package.resolved b/Package.resolved index bbce4e893..ebadf1983 100644 --- a/Package.resolved +++ b/Package.resolved @@ -18,6 +18,15 @@ "version" : "0.2.3" } }, + { + "identity" : "codeedittextview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", + "state" : { + "revision" : "d7ac3f11f22ec2e820187acce8f3a3fb7aa8ddec", + "version" : "0.12.1" + } + }, { "identity" : "rearrange", "kind" : "remoteSourceControl", diff --git a/Sources/CodeEditSourceEditor/Utils/CursorPosition.swift b/Sources/CodeEditSourceEditor/Utils/CursorPosition.swift index 23f260fbc..805b874f2 100644 --- a/Sources/CodeEditSourceEditor/Utils/CursorPosition.swift +++ b/Sources/CodeEditSourceEditor/Utils/CursorPosition.swift @@ -72,7 +72,7 @@ public struct CursorPosition: Sendable, Codable, Equatable, Hashable { /// - range: The range of the position. /// - start: The start position of the range. /// - end: The end position of the range. - package init(range: NSRange, start: Position, end: Position?) { + init(range: NSRange, start: Position, end: Position?) { self.range = range self.start = start self.end = end diff --git a/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift b/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift index c17feea24..bef6615bd 100644 --- a/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift +++ b/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift @@ -339,8 +339,8 @@ final class TextViewControllerTests: XCTestCase { XCTAssert(controller.text == "\nHello World with newline!") XCTAssertEqual(controller.cursorPositions.count, 1) - XCTAssertEqual(controller.cursorPositions[0].line, 2) - XCTAssertEqual(controller.cursorPositions[0].column, 1) + XCTAssertEqual(controller.cursorPositions[0].start.line, 2) + XCTAssertEqual(controller.cursorPositions[0].start.column, 1) XCTAssertEqual(controller.cursorPositions[0].range.location, 1) XCTAssertEqual(controller.cursorPositions[0].range.length, 2) XCTAssertEqual(controller.textView.selectionManager.textSelections.count, 1) @@ -359,8 +359,8 @@ final class TextViewControllerTests: XCTestCase { XCTAssertEqual(controller.cursorPositions.count, 1) XCTAssertEqual(controller.cursorPositions[0].range.location, 0) XCTAssertEqual(controller.cursorPositions[0].range.length, 5) - XCTAssertEqual(controller.cursorPositions[0].line, 1) - XCTAssertEqual(controller.cursorPositions[0].column, 1) + XCTAssertEqual(controller.cursorPositions[0].start.line, 1) + XCTAssertEqual(controller.cursorPositions[0].start.column, 1) // Test an invalid position is ignored controller.setCursorPositions([CursorPosition(range: NSRange(location: -1, length: 25))]) @@ -372,8 +372,8 @@ final class TextViewControllerTests: XCTestCase { XCTAssertEqual(controller.cursorPositions.count, 1) XCTAssertEqual(controller.cursorPositions[0].range.location, 2) XCTAssertEqual(controller.cursorPositions[0].range.length, 0) - XCTAssertEqual(controller.cursorPositions[0].line, 2) - XCTAssertEqual(controller.cursorPositions[0].column, 1) + XCTAssertEqual(controller.cursorPositions[0].start.line, 2) + XCTAssertEqual(controller.cursorPositions[0].start.column, 1) // Test order and validity of multiple positions. controller.setCursorPositions([ @@ -383,12 +383,12 @@ final class TextViewControllerTests: XCTestCase { XCTAssertEqual(controller.cursorPositions.count, 2) XCTAssertEqual(controller.cursorPositions[0].range.location, 2) XCTAssertEqual(controller.cursorPositions[0].range.length, 0) - XCTAssertEqual(controller.cursorPositions[0].line, 2) - XCTAssertEqual(controller.cursorPositions[0].column, 1) + XCTAssertEqual(controller.cursorPositions[0].start.line, 2) + XCTAssertEqual(controller.cursorPositions[0].start.column, 1) XCTAssertEqual(controller.cursorPositions[1].range.location, 5) XCTAssertEqual(controller.cursorPositions[1].range.length, 1) - XCTAssertEqual(controller.cursorPositions[1].line, 3) - XCTAssertEqual(controller.cursorPositions[1].column, 2) + XCTAssertEqual(controller.cursorPositions[1].start.line, 3) + XCTAssertEqual(controller.cursorPositions[1].start.column, 2) } func test_cursorPositionRowColInit() { @@ -400,8 +400,8 @@ final class TextViewControllerTests: XCTestCase { XCTAssertEqual(controller.cursorPositions.count, 1) XCTAssertEqual(controller.cursorPositions[0].range.location, 0) XCTAssertEqual(controller.cursorPositions[0].range.length, 0) - XCTAssertEqual(controller.cursorPositions[0].line, 1) - XCTAssertEqual(controller.cursorPositions[0].column, 1) + XCTAssertEqual(controller.cursorPositions[0].start.line, 1) + XCTAssertEqual(controller.cursorPositions[0].start.column, 1) // Test an invalid position is ignored controller.setCursorPositions([CursorPosition(line: -1, column: 10)]) @@ -413,8 +413,8 @@ final class TextViewControllerTests: XCTestCase { XCTAssertEqual(controller.cursorPositions.count, 1) XCTAssertEqual(controller.cursorPositions[0].range.location, 2) XCTAssertEqual(controller.cursorPositions[0].range.length, 0) - XCTAssertEqual(controller.cursorPositions[0].line, 2) - XCTAssertEqual(controller.cursorPositions[0].column, 1) + XCTAssertEqual(controller.cursorPositions[0].start.line, 2) + XCTAssertEqual(controller.cursorPositions[0].start.column, 1) // Test order and validity of multiple positions. controller.setCursorPositions([ @@ -424,12 +424,12 @@ final class TextViewControllerTests: XCTestCase { XCTAssertEqual(controller.cursorPositions.count, 2) XCTAssertEqual(controller.cursorPositions[0].range.location, 0) XCTAssertEqual(controller.cursorPositions[0].range.length, 0) - XCTAssertEqual(controller.cursorPositions[0].line, 1) - XCTAssertEqual(controller.cursorPositions[0].column, 1) + XCTAssertEqual(controller.cursorPositions[0].start.line, 1) + XCTAssertEqual(controller.cursorPositions[0].start.column, 1) XCTAssertEqual(controller.cursorPositions[1].range.location, 4) XCTAssertEqual(controller.cursorPositions[1].range.length, 0) - XCTAssertEqual(controller.cursorPositions[1].line, 3) - XCTAssertEqual(controller.cursorPositions[1].column, 1) + XCTAssertEqual(controller.cursorPositions[1].start.line, 3) + XCTAssertEqual(controller.cursorPositions[1].start.column, 1) } // MARK: - TreeSitterClient diff --git a/Tests/CodeEditSourceEditorTests/TagEditingTests.swift b/Tests/CodeEditSourceEditorTests/TagEditingTests.swift index e7118fd9f..6a0460739 100644 --- a/Tests/CodeEditSourceEditorTests/TagEditingTests.swift +++ b/Tests/CodeEditSourceEditorTests/TagEditingTests.swift @@ -69,7 +69,7 @@ final class TagEditingTests: XCTestCase { ) XCTAssertEqual( controller.cursorPositions[0], - CursorPosition(range: NSRange(location: 43, length: 0), line: 4, column: 13) + CursorPosition(range: NSRange(location: 43, length: 0), start: .init(line: 4, column: 13), end: nil) ) } @@ -85,7 +85,7 @@ final class TagEditingTests: XCTestCase { ) XCTAssertEqual( controller.cursorPositions[0], - CursorPosition(range: NSRange(location: 7, length: 0), line: 2, column: 1) + CursorPosition(range: NSRange(location: 7, length: 0), start: .init(line: 2, column: 1), end: nil) ) } From e606a3d89bf43d5c79633f9a03886a5e18dd6410 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 30 Jul 2025 13:16:43 -0500 Subject: [PATCH 40/41] Add Key Command Handler --- .../TextViewController+Lifecycle.swift | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift index 81349b623..bb654a45b 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift @@ -213,7 +213,7 @@ extension TextViewController { if event.keyCode == tabKey { return self.handleTab(event: event, modifierFlags: modifierFlags.rawValue) } else { - return self.handleCommand(event: event, modifierFlags: modifierFlags.rawValue) + return self.handleCommand(event: event, modifierFlags: modifierFlags) } case .flagsChanged: if modifierFlags.contains(.command), @@ -243,9 +243,9 @@ extension TextViewController { } } - func handleCommand(event: NSEvent, modifierFlags: UInt) -> NSEvent? { - let commandKey = NSEvent.ModifierFlags.command.rawValue - let controlKey = NSEvent.ModifierFlags.control.rawValue + func handleCommand(event: NSEvent, modifierFlags: NSEvent.ModifierFlags) -> NSEvent? { + let commandKey = NSEvent.ModifierFlags.command + let controlKey = NSEvent.ModifierFlags.control switch (modifierFlags, event.charactersIgnoringModifiers) { case (commandKey, "/"): @@ -261,7 +261,7 @@ extension TextViewController { _ = self.textView.resignFirstResponder() self.findViewController?.showFindPanel() return nil - case (0, "\u{1b}"): // Escape key + case (.init(rawValue: 0), "\u{1b}"): // Escape key if findViewController?.viewModel.isShowingFindPanel == true { self.findViewController?.hideFindPanel() return nil @@ -270,6 +270,12 @@ extension TextViewController { return handleShowCompletions(event) case (controlKey, " "): return handleShowCompletions(event) + case ([NSEvent.ModifierFlags.command, NSEvent.ModifierFlags.control], "j"): + guard let cursor = cursorPositions.first else { + return event + } + jumpToDefinitionModel?.performJump(at: cursor.range) + return nil case (_, _): return event } From 08ec86769137c0c7420c7c0dc4b621f9cc78cc51 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 8 Aug 2025 09:21:27 -0500 Subject: [PATCH 41/41] Fix Typo Co-authored-by: Wesley de Groot --- .../CodeEditSourceEditorExample/Views/ContentView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index a12af2b95..070141b87 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -1,4 +1,4 @@ -z// +// // ContentView.swift // CodeEditSourceEditorExample //