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 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/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index 97306b73a..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( diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift new file mode 100644 index 000000000..e08d85921 --- /dev/null +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/MockCompletionDelegate.swift @@ -0,0 +1,113 @@ +// +// MockCompletionDelegate.swift +// CodeEditSourceEditorExample +// +// Created by Khan Winter on 7/22/25. +// + +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 { + 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.label = text + } + } + + private func randomSuggestions(_ count: Int? = nil) -> [Suggestion] { + let count = 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, randomSuggestions()) + } + + func completionOnCursorMove( + textView: TextViewController, + cursorPosition: CursorPosition + ) -> [CodeSuggestionEntry]? { + 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? + ) { + guard let suggestion = item as? Suggestion else { + return + } + textView.textView.undoManager?.beginUndoGrouping() + textView.textView.insertText(suggestion.label) + textView.textView.undoManager?.endUndoGrouping() + } +} diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift index 40e19215b..a4b5c66f4 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) diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionDelegate.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/CodeSuggestionDelegate.swift new file mode 100644 index 000000000..c313ffcdb --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/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/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/Model/SuggestionViewModel.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift new file mode 100644 index 000000000..91fe22fec --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift @@ -0,0 +1,112 @@ +// +// 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 { + defer { itemsRequestTask = nil } + + 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 + ) { + guard itemsRequestTask == nil else { return } + + if activeTextView !== textView { + close() + return + } + + guard let newItems = delegate.completionOnCursorMove( + textView: textView, + cursorPosition: position + ), + !newItems.isEmpty 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 + } +} diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionLabelView.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionLabelView.swift new file mode 100644 index 000000000..748856a38 --- /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: font.charWidth) { + 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/TableView/CodeSuggestionRowView.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/CodeSuggestionRowView.swift new file mode 100644 index 000000000..6e7d7e19b --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/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/TableView/NoSlotScroller.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/NoSlotScroller.swift new file mode 100644 index 000000000..9d194f28e --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/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/TableView/SuggestionViewController.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift new file mode 100644 index 000000000..c9b4524b8 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/TableView/SuggestionViewController.swift @@ -0,0 +1,248 @@ +// +// SuggestionViewController.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 7/22/25. +// + +import AppKit +import SwiftUI +import Combine + +class SuggestionViewController: NSViewController { + var tintView: NSView! + var tableView: NSTableView! + var scrollView: NSScrollView! + 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 + 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 + + 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: 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) + ]) + } + + override func viewWillAppear() { + super.viewWillAppear() + resetScrollPosition() + tableView.reloadData() + if let controller = model?.activeTextView { + styleView(using: controller) + } + } + + func styleView(using controller: TextViewController) { + noItemsLabel.font = controller.font + switch controller.systemAppearance { + case .aqua: + 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: + 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 + (self.view.window?.windowController as? SuggestionController)?.updateWindowSize(newSize: 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.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 + + let newSize = NSSize(width: newWidth, height: newHeight) + (self.view.window?.windowController as? SuggestionController)?.updateWindowSize(newSize: newSize) + } + + 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 = true + 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() + if let activeTextView = model?.activeTextView { + updateSize(using: activeTextView) + } + } + + @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 == false { + 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 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.text.color.withAlphaComponent(0.5), + font: textView.font + ) + ) + } + + 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/Window/SuggestionController+Window.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift new file mode 100644 index 000000000..f54e103ae --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift @@ -0,0 +1,99 @@ +// +// 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 + var newWindowOrigin = NSPoint( + x: cursorRect.origin.x - Self.WINDOW_PADDING, + 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) + } + } + + 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 { + let window = NSWindow( + contentRect: .zero, + styleMask: [.resizable, .fullSizeContentView, .nonactivatingPanel, .utilityWindow], + backing: .buffered, + defer: false + ) + + 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 + + return window + } +} diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift new file mode 100644 index 000000000..140b6e2b2 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift @@ -0,0 +1,185 @@ +// +// SuggestionController.swift +// CodeEditTextView +// +// Created by Abe Malla on 6/18/24. +// + +import AppKit +import CodeEditTextView +import Combine + +public final class SuggestionController: NSWindowController { + static var shared: SuggestionController = SuggestionController() + + // MARK: - Properties + + /// Whether the suggestion window is visible + var isVisible: Bool { + window?.isVisible ?? false + } + + var model: SuggestionViewModel = SuggestionViewModel() + + // MARK: - Private Properties + + /// 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 + + /// Tracks when the window is placed above the cursor + var isWindowAboveCursor = false + + /// An event monitor for keyboard events + private var localEventMonitor: Any? + /// Holds the observer for the window resign notifications + private var windowResignObserver: NSObjectProtocol? + + // MARK: - Initialization + + public init() { + let window = Self.makeWindow() + + let controller = SuggestionViewController() + controller.model = model + window.contentViewController = controller + + super.init(window: window) + + if window.isVisible { + window.close() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Show Completions + + func showCompletions( + textView: TextViewController, + delegate: CodeSuggestionDelegate, + cursorPosition: CursorPosition, + asPopover: Bool = false + ) { + model.showCompletions( + textView: textView, + delegate: delegate, + cursorPosition: cursorPosition + ) { parentWindow, cursorRect in + 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) + } + } + + /// 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) + + // 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() + } + + setupEventMonitors() + super.showWindow(nil) + window.orderFront(nil) + window.contentViewController?.viewWillAppear() + } + + /// 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 + } + + 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 + } + } + + // MARK: - Cursors Updated + + 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/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 e33ac4213..eb075c0f0 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift @@ -73,10 +73,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() configuration.didSetOnController(controller: self, oldConfig: nil) @@ -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, "/"): @@ -228,8 +229,14 @@ extension TextViewController { self.findViewController?.showFindPanel() return nil case (0, "\u{1b}"): // Escape key - self.findViewController?.hideFindPanel() - return nil + if findViewController?.viewModel.isShowingFindPanel == true { + self.findViewController?.hideFindPanel() + return nil + } + // Attempt to show completions otherwise + return handleShowCompletions(event) + case (controlKey, " "): + return handleShowCompletions(event) case (_, _): return event } @@ -252,4 +259,21 @@ extension TextViewController { } return nil } + + private func handleShowCompletions(_ event: NSEvent) -> NSEvent? { + 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, + cursorPosition: cursorPosition + ) + return nil + } + return event + } } 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 769b74399..454c6e0e7 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -45,7 +45,7 @@ public class TextViewController: NSViewController { var _undoManager: CEUndoManager! var systemAppearance: NSAppearance.Name? - var localEvenMonitor: Any? + var localEventMonitor: Any? var isPostingCursorNotification: Bool = false /// A default `NSParagraphStyle` with a set `lineHeight` @@ -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` @@ -266,9 +268,9 @@ 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/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/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 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() + } } } } 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 {