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.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1a0e8e9de..d312fc00e 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/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 e08d85921..fbc71cd19 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 targetPosition: CursorPosition? { nil } - var sourcePreview: String? { nil } + var pathComponents: [String]? + var targetPosition: CursorPosition? = CursorPosition(line: 10, column: 20) + 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]? { + [ + JumpToDefinitionLink( + url: nil, + targetRange: CursorPosition(line: 0, column: 10), + typeName: "Start of Document", + sourcePreview: "// Comment at start" + ), + JumpToDefinitionLink( + url: URL(string: "https://codeedit.app/"), + targetRange: CursorPosition(line: 1024, column: 10), + typeName: "CodeEdit Website", + sourcePreview: "https://codeedit.app/" + ) + ] + } + + 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..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. - return "Line: \(cursorPositions[0].line) Col: \(cursorPositions[0].column) Range: \(cursorPositions[0].range)" + // swiftlint:disable:next line_length + return "Line: \(cursorPositions[0].start.line) Col: \(cursorPositions[0].start.column) Range: \(cursorPositions[0].range)" } } diff --git a/Package.resolved b/Package.resolved index 954e31f14..ebadf1983 100644 --- a/Package.resolved +++ b/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "8f02a6b206091ee4aaee9006e2ef1ddc68e754c8", - "version" : "0.11.4" + "revision" : "d7ac3f11f22ec2e820187acce8f3a3fb7aa8ddec", + "version" : "0.12.1" } }, { @@ -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/Package.swift b/Package.swift index 1fc3520aa..da9e3832c 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,7 @@ let package = Package( // A fast, efficient, text view for code. .package( url: "https://github.com/CodeEditApp/CodeEditTextView.git", - from: "0.12.0" + from: "0.12.1" ), // tree-sitter languages .package( diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionDelegate.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionDelegate.swift index c313ffcdb..1d80fa3d9 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/CodeSuggestionEntry.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionEntry.swift index 981ad7dc5..abf87398b 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionEntry.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionEntry.swift @@ -12,6 +12,7 @@ import SwiftUI public protocol CodeSuggestionEntry { var label: String { get } var detail: String? { get } + var documentation: String? { get } /// Leave as `nil` if the link is in the same document. var pathComponents: [String]? { get } diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift index 91fe22fec..2b66bf3ae 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift @@ -7,15 +7,16 @@ import AppKit +@MainActor 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 - } + weak var delegate: CodeSuggestionDelegate? + + private var syntaxHighlightedCache: [Int: NSAttributedString] = [:] func showCompletions( textView: TextViewController, @@ -24,11 +25,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 { defer { itemsRequestTask = nil } @@ -55,6 +58,7 @@ final class SuggestionViewModel: ObservableObject { } self.items = completionItems.items + self.syntaxHighlightedCache = [:] showWindowOnParent(targetParentWindow, cursorRect) } } catch { @@ -93,14 +97,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() } @@ -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 new file mode 100644 index 000000000..bcf0f4fae --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionPreviewView.swift @@ -0,0 +1,169 @@ +// +// CodeSuggestionPreviewView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 7/28/25. +// + +import SwiftUI + +final class CodeSuggestionPreviewView: NSVisualEffectView { + private let spacing: CGFloat = 5 + + var sourcePreview: NSAttributedString? { + didSet { + sourcePreviewLabel.attributedStringValue = sourcePreview ?? NSAttributedString(string: "") + 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 = .systemFont(ofSize: font.pointSize) + } + } + 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) + + pathComponentsLabel.maximumNumberOfLines = 1 + pathComponentsLabel.lineBreakMode = .byTruncatingMiddle + pathComponentsLabel.usesSingleLineMode = true + + 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 hideIfEmpty() { + isHidden = sourcePreview == nil && documentation == nil && pathComponents.isEmpty + } + + func setPreferredMaxLayoutWidth(width: CGFloat) { + sourcePreviewLabel.preferredMaxLayoutWidth = width + documentationLabel.preferredMaxLayoutWidth = width + pathComponentsLabel.preferredMaxLayoutWidth = width + } + + private func styleStaticLabel(_ label: NSTextField) { + label.isEditable = false + label.isSelectable = true + label.allowsDefaultTighteningForTruncation = false + label.isBezeled = false + label.isBordered = false + label.backgroundColor = .clear + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + } + + private func configurePathComponentsLabel() { + pathComponentsLabel.isHidden = pathComponents.isEmpty + + let folder = NSTextAttachment() + folder.image = NSImage(systemSymbolName: "folder.fill", accessibilityDescription: nil)? + .withSymbolConfiguration( + .init(paletteColors: [NSColor.systemBlue]).applying(.init(pointSize: font.pointSize, weight: .regular)) + ) + + let string: NSMutableAttributedString = NSMutableAttributedString(attachment: folder) + string.append(NSAttributedString(string: " ")) + + let separator = NSTextAttachment() + separator.image = NSImage(systemSymbolName: "chevron.compact.right", accessibilityDescription: nil)? + .withSymbolConfiguration( + .init(paletteColors: [NSColor.labelColor]) + .applying(.init(pointSize: font.pointSize + 1, weight: .regular)) + ) + + for (idx, component) in pathComponents.enumerated() { + string.append(NSAttributedString(string: component, attributes: [.foregroundColor: NSColor.labelColor])) + if idx != pathComponents.count - 1 { + string.append(NSAttributedString(string: " ")) + string.append(NSAttributedString(attachment: separator)) + string.append(NSAttributedString(string: " ")) + } + } + + if let targetRange { + string.append(NSAttributedString(string: ":\(targetRange.start.line)")) + if targetRange.start.column > 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 c9b4524b8..c9b576101 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 = model.syntaxHighlights(forIndex: tableView.selectedRow) + 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/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 140b6e2b2..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,8 +31,8 @@ public final class SuggestionController: NSWindowController { /// Tracks when the window is placed above the cursor var isWindowAboveCursor = false - /// An event monitor for keyboard events - private var localEventMonitor: Any? + var popover: NSPopover? + /// Holds the observer for the window resign notifications private var windowResignObserver: NSObjectProtocol? @@ -47,6 +47,8 @@ public final class SuggestionController: NSWindowController { super.init(window: window) + controller.windowController = self + if window.isVisible { window.close() } @@ -70,17 +72,31 @@ 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.contentViewController as? SuggestionViewController)?.styleView(using: textView) } } @@ -102,7 +118,6 @@ public final class SuggestionController: NSWindowController { self?.close() } - setupEventMonitors() super.showWindow(nil) window.orderFront(nil) window.contentViewController?.viewWillAppear() @@ -111,59 +126,15 @@ public final class SuggestionController: NSWindowController { /// Close the window public override func close() { model.willClose() - removeEventMonitors() - 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 - } - } - } - private func checkKeyDownEvents(_ event: NSEvent) -> NSEvent? { - if !self.isVisible { - return event + if popover != nil { + popover?.close() + popover = nil + } else { + contentViewController?.viewWillDisappear() } - 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 @@ -172,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/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/Controller/TextViewController+Lifecycle.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift index eb075c0f0..bb654a45b 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, .leftMouseUp] + ) { [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,21 +200,52 @@ extension TextViewController { // Only handle commands if this is the key window and text view is first responder guard isKeyWindow && isFirstResponder else { return event } + return handleEvent(event: event) + } + } - let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + 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) + 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), + 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: + return event } } - 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, "/"): @@ -228,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 @@ -237,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 } @@ -247,10 +286,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 diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 454c6e0e7..3f662b41e 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -173,13 +173,19 @@ 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 /// 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 +229,7 @@ public class TextViewController: NSViewController { self.treeSitterClient = client } - self.textView = TextView( + self.textView = SourceEditorTextView( string: string, font: font, textColor: theme.text.color, @@ -242,6 +248,12 @@ public class TextViewController: NSViewController { $0.prepareCoordinator(controller: self) } self.textCoordinators = coordinators.map { WeakCoordinator($0) } + + jumpToDefinitionModel = JumpToDefinitionModel( + controller: self, + treeSitterClient: treeSitterClient, + delegate: nil + ) } required init?(coder: NSCoder) { 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).. [JumpToDefinitionLink]? + func openLink(link: JumpToDefinitionLink) +} diff --git a/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLink.swift b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLink.swift new file mode 100644 index 000000000..0c4bec240 --- /dev/null +++ b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionLink.swift @@ -0,0 +1,48 @@ +// +// JumpToDefinitionLink.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 7/23/25. +// + +import Foundation +import SwiftUI + +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 var targetPosition: CursorPosition? { + targetRange + } + public let targetRange: CursorPosition + + public let label: 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 pathComponents: [String]? { url?.relativePath.components(separatedBy: "/") ?? [] } + public var deprecated: Bool { false } + + public init( + url: URL?, + 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 + } +} diff --git a/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift new file mode 100644 index 000000000..55a31fab2 --- /dev/null +++ b/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift @@ -0,0 +1,196 @@ +// +// 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? + weak var treeSitterClient: TreeSitterClient? + + private weak var controller: TextViewController? + + private(set) public var hoveredRange: NSRange? + + private var hoverRequestTask: Task? + private var jumpRequestTask: Task? + + 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 + } + + // 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 { + currentLinks = nil + guard let controller, + let links = await delegate?.queryLinks(forRange: location, textView: controller), + !links.isEmpty else { + NSSound.beep() + if let textView { + BezelNotification.show(symbolName: "questionmark", over: textView) + } + return + } + if links.count == 1 { + let link = links[0] + if link.url != nil { + delegate?.openLink(link: link) + } else { + textView?.selectionManager.setSelectedRange(link.targetRange.range) + } + + textView?.scrollSelectionToVisible() + } else { + presentLinkPopover(on: location, links: links) + } + + cancelHover() + } + } + + // MARK: - Link Popover + + 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, + 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 + + private func openLocalLink(link: JumpToDefinitionLink) { + guard let controller = controller, let range = controller.resolveCursorPosition(link.targetRange) else { + return + } + controller.textView.selectionManager.setSelectedRange(range.range) + controller.textView.scrollSelectionToVisible() + } + + // 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 + ) + } +} + +extension JumpToDefinitionModel: CodeSuggestionDelegate { + func completionSuggestionsRequested( + textView: TextViewController, + cursorPosition: CursorPosition + ) async -> (windowPosition: CursorPosition, items: [CodeSuggestionEntry])? { + guard let links = currentLinks else { return nil } + defer { self.currentLinks = nil } + return (cursorPosition, links) + } + + func completionOnCursorMove( + textView: TextViewController, + cursorPosition: CursorPosition + ) -> [CodeSuggestionEntry]? { + nil + } + + func completionWindowApplyCompletion( + item: CodeSuggestionEntry, + textView: TextViewController, + cursorPosition: CursorPosition? + ) { + guard let link = item as? JumpToDefinitionLink else { return } + if link.url != nil { + delegate?.openLink(link: link) + } else { + openLocalLink(link: link) + } + } +} diff --git a/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift b/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift index 309edc2f3..5a1d63367 100644 --- a/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift +++ b/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift @@ -84,7 +84,7 @@ struct RangeStore: 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.. [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..5bc271fb6 --- /dev/null +++ b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Temporary.swift @@ -0,0 +1,71 @@ +// +// TreeSitterClient+Temporary.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 7/24/25. +// + +import AppKit +import SwiftTreeSitter +import CodeEditLanguages + +extension TreeSitterClient { + 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 + } + } +} diff --git a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterExecutor.swift b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterExecutor.swift index b9e30873c..fbba65741 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 }) diff --git a/Sources/CodeEditSourceEditor/Utils/CursorPosition.swift b/Sources/CodeEditSourceEditor/Utils/CursorPosition.swift index 508684527..805b874f2 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. + 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? } 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) ) }