From 36b86c417f9b504c424214f0959085ec46c6a135 Mon Sep 17 00:00:00 2001 From: Abe M Date: Sat, 19 Oct 2024 22:31:19 -0700 Subject: [PATCH 1/7] Updates --- Package.resolved | 13 ++----------- Package.swift | 5 +++-- .../CodeEditSourceEditor/CodeEditSourceEditor.swift | 10 +++------- .../Controller/TextViewController+LoadView.swift | 5 +++-- .../Controller/TextViewController.swift | 9 ++++----- .../TextView+/TextView+TextFormation.swift | 2 +- .../CodeEditSourceEditor/Gutter/GutterView.swift | 2 +- 7 files changed, 17 insertions(+), 29 deletions(-) diff --git a/Package.resolved b/Package.resolved index 4b1c88c86..2eedaba41 100644 --- a/Package.resolved +++ b/Package.resolved @@ -9,15 +9,6 @@ "version" : "0.1.19" } }, - { - "identity" : "codeedittextview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", - "state" : { - "revision" : "2619cb945b4d6c2fc13f22ba873ba891f552b0f3", - "version" : "0.7.6" - } - }, { "identity" : "mainoffender", "kind" : "remoteSourceControl", @@ -41,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", - "version" : "1.1.2" + "revision" : "9bf03ff58ce34478e66aaee630e491823326fd06", + "version" : "1.1.3" } }, { diff --git a/Package.swift b/Package.swift index b2efb53a3..25b53df4f 100644 --- a/Package.swift +++ b/Package.swift @@ -16,8 +16,9 @@ let package = Package( dependencies: [ // A fast, efficient, text view for code. .package( - url: "https://github.com/CodeEditApp/CodeEditTextView.git", - from: "0.7.6" +// url: "https://github.com/CodeEditApp/CodeEditTextView.git", +// from: "0.7.6" + path: "../CodeEditTextView" ), // tree-sitter languages .package( diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift index 2f856a5d9..98402bde2 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift @@ -42,7 +42,6 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// character's width between characters, etc. Defaults to `1.0` /// - bracketPairHighlight: The type of highlight to use to highlight bracket pairs. /// See `BracketPairHighlight` for more information. Defaults to `nil` - /// - useSystemCursor: If true, uses the system cursor on `>=macOS 14`. /// - undoManager: The undo manager for the text view. Defaults to `nil`, which will create a new CEUndoManager /// - coordinators: Any text coordinators for the view to use. See ``TextViewCoordinator`` for more information. public init( @@ -191,6 +190,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { public typealias NSViewControllerType = TextViewController + // TODO: SET COMPLETIONPROVIDER FOR TEXTVIEW public func makeNSViewController(context: Context) -> TextViewController { let controller = TextViewController( string: "", @@ -235,6 +235,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { Coordinator(text: text, cursorPositions: cursorPositions) } + // TODO: SET COMPLETIONPROVIDER FOR TEXTVIEW public func updateNSViewController(_ controller: TextViewController, context: Context) { if !context.coordinator.isUpdateFromTextView { // Prevent infinite loop of update notifications @@ -301,10 +302,6 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { controller.letterSpacing = letterSpacing } - if controller.useSystemCursor != useSystemCursor { - controller.useSystemCursor = useSystemCursor - } - controller.bracketPairHighlight = bracketPairHighlight } @@ -325,8 +322,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { controller.indentOption == indentOption && controller.tabWidth == tabWidth && controller.letterSpacing == letterSpacing && - controller.bracketPairHighlight == bracketPairHighlight && - controller.useSystemCursor == useSystemCursor + controller.bracketPairHighlight == bracketPairHighlight } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 34eb0dd42..607d2ca0b 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -110,10 +110,10 @@ extension TextViewController { } .store(in: &cancellables) - if let localEventMonitor = self.localEvenMonitor { + if let localEventMonitor = self.localEventMonitor { NSEvent.removeMonitor(localEventMonitor) } - self.localEvenMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + self.localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in guard self?.view.window?.firstResponder == self?.textView else { return event } let tabKey: UInt16 = 0x30 @@ -126,6 +126,7 @@ extension TextViewController { } } } + func handleCommand(event: NSEvent, modifierFlags: UInt) -> NSEvent? { let commandKey = NSEvent.ModifierFlags.command.rawValue diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 4dbf282a2..57e1de7df 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -28,7 +28,7 @@ public class TextViewController: NSViewController { internal var highlightLayers: [CALayer] = [] internal var systemAppearance: NSAppearance.Name? - package var localEvenMonitor: Any? + package var localEventMonitor: Any? package var isPostingCursorNotification: Bool = false /// The string contents. @@ -254,7 +254,6 @@ public class TextViewController: NSViewController { isEditable: isEditable, isSelectable: isSelectable, letterSpacing: letterSpacing, - useSystemCursor: platformGuardedSystemCursor, delegate: self ) @@ -305,10 +304,10 @@ public class TextViewController: NSViewController { textCoordinators.removeAll() NotificationCenter.default.removeObserver(self) cancellables.forEach { $0.cancel() } - if let localEvenMonitor { - NSEvent.removeMonitor(localEvenMonitor) + if let localEventMonitor { + NSEvent.removeMonitor(localEventMonitor) } - localEvenMonitor = nil + localEventMonitor = nil } } diff --git a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift index 99e80effb..a9af9e2e0 100644 --- a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift +++ b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift @@ -46,7 +46,7 @@ extension TextView: TextInterface { textStorage.beginEditing() layoutManager.willReplaceCharactersInRange(range: mutation.range, with: mutation.string) - _undoManager?.registerMutation(mutation) +// _undoManager?.registerMutation(mutation) textStorage.replaceCharacters(in: mutation.range, with: mutation.string) selectionManager.didReplaceCharacters( in: mutation.range, diff --git a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift index 1cf44ea7e..6c8044a8a 100644 --- a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift @@ -7,7 +7,7 @@ import AppKit import CodeEditTextView -import CodeEditTextViewObjC +//import CodeEditTextViewObjC public protocol GutterViewDelegate: AnyObject { func gutterViewWidthDidUpdate(newWidth: CGFloat) From a1431443127588359dd7e76df357054a5a86204d Mon Sep 17 00:00:00 2001 From: Abe M Date: Tue, 17 Dec 2024 23:27:10 -0800 Subject: [PATCH 2/7] ItemBox updates --- Package.resolved | 9 --------- .../Controller/TextViewController+IndentLines.swift | 2 +- Sources/CodeEditSourceEditor/Gutter/GutterView.swift | 2 +- .../TreeSitter/TreeSitterClient+Query.swift | 2 +- 4 files changed, 3 insertions(+), 12 deletions(-) diff --git a/Package.resolved b/Package.resolved index 601a5585b..76ffd3f8e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -9,15 +9,6 @@ "version" : "0.1.20" } }, - { - "identity" : "mainoffender", - "kind" : "remoteSourceControl", - "location" : "https://github.com/mattmassicotte/MainOffender", - "state" : { - "revision" : "343cc3797618c29b48b037b4e2beea0664e75315", - "version" : "0.1.0" - } - }, { "identity" : "rearrange", "kind" : "remoteSourceControl", diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift index 8d690b76f..cad90cfa9 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift @@ -23,7 +23,7 @@ extension TextViewController { guard !cursorPositions.isEmpty else { return } textView.undoManager?.beginUndoGrouping() -for cursorPosition in self.cursorPositions.reversed() { + for cursorPosition in self.cursorPositions.reversed() { // get lineindex, i.e line-numbers+1 guard let lineIndexes = getHighlightedLines(for: cursorPosition.range) else { continue } diff --git a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift index 80fc1a714..31568d4a1 100644 --- a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift @@ -7,7 +7,7 @@ import AppKit import CodeEditTextView -//import CodeEditTextViewObjC +import CodeEditTextViewObjC public protocol GutterViewDelegate: AnyObject { func gutterViewWidthDidUpdate(newWidth: CGFloat) diff --git a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift index 0795c15f3..f7f73c3ba 100644 --- a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift @@ -12,7 +12,7 @@ import SwiftTreeSitter // Functions for querying and navigating the tree-sitter node tree. These functions should throw if not able to be // performed asynchronously as (currently) any editing tasks that would use these must be performed synchronously. -extension TreeSitterClient { +public extension TreeSitterClient { public struct NodeResult { let id: TreeSitterLanguage let language: Language From 32a7756751548a85369583605082a89b39244336 Mon Sep 17 00:00:00 2001 From: Abe M Date: Sun, 22 Dec 2024 03:40:12 -0800 Subject: [PATCH 3/7] Small update --- .../TreeSitter/TreeSitterClient+Query.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift index f7f73c3ba..0795c15f3 100644 --- a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift @@ -12,7 +12,7 @@ import SwiftTreeSitter // Functions for querying and navigating the tree-sitter node tree. These functions should throw if not able to be // performed asynchronously as (currently) any editing tasks that would use these must be performed synchronously. -public extension TreeSitterClient { +extension TreeSitterClient { public struct NodeResult { let id: TreeSitterLanguage let language: Language From 924d86fcde0786c998b22c0384057e591d9bea77 Mon Sep 17 00:00:00 2001 From: Abe M Date: Sun, 22 Dec 2024 03:42:29 -0800 Subject: [PATCH 4/7] Small update --- .../Extensions/TextView+/TextView+TextFormation.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift index a9af9e2e0..99e80effb 100644 --- a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift +++ b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift @@ -46,7 +46,7 @@ extension TextView: TextInterface { textStorage.beginEditing() layoutManager.willReplaceCharactersInRange(range: mutation.range, with: mutation.string) -// _undoManager?.registerMutation(mutation) + _undoManager?.registerMutation(mutation) textStorage.replaceCharacters(in: mutation.range, with: mutation.string) selectionManager.didReplaceCharacters( in: mutation.range, From 99472561cbf38233e30b03aed49b1ad15a621f7d Mon Sep 17 00:00:00 2001 From: Abe M Date: Thu, 26 Dec 2024 17:58:46 -0800 Subject: [PATCH 5/7] Moved code from TextView, added more functionality to delegate --- Package.swift | 5 +- .../CodeSuggestion/NoSlotScroller.swift | 16 + .../SuggestionController+Window.swift | 284 ++++++++++++++++++ .../CodeSuggestion/SuggestionController.swift | 262 ++++++++++++++++ .../TreeSitter/TreeSitterClient+Query.swift | 2 +- 5 files changed, 565 insertions(+), 4 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/CodeSuggestion/NoSlotScroller.swift create mode 100644 Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift create mode 100644 Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift diff --git a/Package.swift b/Package.swift index 69b0925ac..cf1f67303 100644 --- a/Package.swift +++ b/Package.swift @@ -16,9 +16,8 @@ let package = Package( dependencies: [ // A fast, efficient, text view for code. .package( -// url: "https://github.com/CodeEditApp/CodeEditTextView.git", -// from: "0.7.7" - path: "../CodeEditTextView" + url: "https://github.com/CodeEditApp/CodeEditTextView.git", + from: "0.7.7" ), // tree-sitter languages .package( diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/NoSlotScroller.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/NoSlotScroller.swift new file mode 100644 index 000000000..9d194f28e --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/NoSlotScroller.swift @@ -0,0 +1,16 @@ +// +// NoSlotScroller.swift +// CodeEditSourceEditor +// +// Created by Abe Malla on 12/26/24. +// + +import AppKit + +class NoSlotScroller: NSScroller { + override class var isCompatibleWithOverlayScrollers: Bool { true } + + override func drawKnobSlot(in slotRect: NSRect, highlight flag: Bool) { + // Don't draw the knob slot (the background track behind the knob) + } +} diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift new file mode 100644 index 000000000..9fb794f90 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift @@ -0,0 +1,284 @@ +// +// SuggestionController+Window.swift +// CodeEditTextView +// +// Created by Abe Malla on 12/22/24. +// + +import AppKit + +extension SuggestionController { + /// Will constrain the window's frame to be within the visible screen + public func constrainWindowToScreenEdges(cursorRect: NSRect) { + guard let window = self.window, + let screenFrame = window.screen?.visibleFrame else { + return + } + + let windowSize = window.frame.size + let padding: CGFloat = 22 + // TODO: PASS IN OFFSET + var newWindowOrigin = NSPoint( + x: cursorRect.origin.x - Self.WINDOW_PADDING - 13 - 16.5, + y: cursorRect.origin.y + ) + + // Keep the horizontal position within the screen and some padding + let minX = screenFrame.minX + padding + let maxX = screenFrame.maxX - windowSize.width - padding + + if newWindowOrigin.x < minX { + newWindowOrigin.x = minX + } else if newWindowOrigin.x > maxX { + newWindowOrigin.x = maxX + } + + // Check if the window will go below the screen + // We determine whether the window drops down or upwards by choosing which + // corner of the window we will position: `setFrameOrigin` or `setFrameTopLeftPoint` + if newWindowOrigin.y - windowSize.height < screenFrame.minY { + // If the cursor itself is below the screen, then position the window + // at the bottom of the screen with some padding + if newWindowOrigin.y < screenFrame.minY { + newWindowOrigin.y = screenFrame.minY + padding + } else { + // Place above the cursor + newWindowOrigin.y += cursorRect.height + } + + isWindowAboveCursor = true + window.setFrameOrigin(newWindowOrigin) + } else { + // If the window goes above the screen, position it below the screen with padding + let maxY = screenFrame.maxY - padding + if newWindowOrigin.y > maxY { + newWindowOrigin.y = maxY + } + + isWindowAboveCursor = false + window.setFrameTopLeftPoint(newWindowOrigin) + } + } + + // MARK: - Private Methods + + static func makeWindow() -> NSWindow { + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: self.DEFAULT_SIZE), + styleMask: [.resizable, .fullSizeContentView, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + configureWindow(window) + configureWindowContent(window) + return window + } + + static func configureWindow(_ window: NSWindow) { + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.isExcludedFromWindowsMenu = true + window.isReleasedWhenClosed = false + window.level = .popUpMenu + window.hasShadow = true + window.isOpaque = false + window.tabbingMode = .disallowed + window.hidesOnDeactivate = true + window.backgroundColor = .clear + window.minSize = Self.DEFAULT_SIZE + } + + static func configureWindowContent(_ window: NSWindow) { + guard let contentView = window.contentView else { return } + + contentView.wantsLayer = true + // TODO: GET COLOR FROM THEME + contentView.layer?.backgroundColor = CGColor( + srgbRed: 31.0 / 255.0, + green: 31.0 / 255.0, + blue: 36.0 / 255.0, + alpha: 1.0 + ) + contentView.layer?.cornerRadius = 8.5 + contentView.layer?.borderWidth = 1 + contentView.layer?.borderColor = NSColor.gray.withAlphaComponent(0.45).cgColor + + let innerShadow = NSShadow() + innerShadow.shadowColor = NSColor.black.withAlphaComponent(0.1) + innerShadow.shadowOffset = NSSize(width: 0, height: -1) + innerShadow.shadowBlurRadius = 2 + contentView.shadow = innerShadow + } + + func configureTableView() { + tableView.delegate = self + tableView.dataSource = self + tableView.headerView = nil + tableView.backgroundColor = .clear + tableView.intercellSpacing = .zero + tableView.allowsEmptySelection = false + tableView.selectionHighlightStyle = .regular + tableView.style = .plain + tableView.usesAutomaticRowHeights = false + tableView.rowSizeStyle = .custom + tableView.rowHeight = 21 + tableView.gridStyleMask = [] + tableView.target = self + tableView.action = #selector(tableViewClicked(_:)) + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("ItemsCell")) + tableView.addTableColumn(column) + } + + @objc private func tableViewClicked(_ sender: Any?) { + if NSApp.currentEvent?.clickCount == 2 { + let row = tableView.selectedRow + guard row >= 0, row < items.count else { + return + } + let selectedItem = items[row] + delegate?.applyCompletionItem(item: selectedItem) + self.close() + } + } + + func configureScrollView() { + scrollView.documentView = tableView + scrollView.hasVerticalScroller = true + scrollView.verticalScroller = NoSlotScroller() + scrollView.scrollerStyle = .overlay + scrollView.autohidesScrollers = true + scrollView.drawsBackground = false + scrollView.automaticallyAdjustsContentInsets = false + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.verticalScrollElasticity = .allowed + scrollView.contentInsets = NSEdgeInsets( + top: Self.WINDOW_PADDING, + left: 0, + bottom: Self.WINDOW_PADDING, + right: 0 + ) + + guard let contentView = window?.contentView else { return } + contentView.addSubview(scrollView) + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: contentView.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + } + + /// Updates the item box window's height based on the number of items. + /// If there are no items, the default label will be displayed instead. + func updateSuggestionWindowAndContents() { + guard let window = self.window else { + return + } + + noItemsLabel.isHidden = !items.isEmpty + scrollView.isHidden = items.isEmpty + + // Update window dimensions + let numberOfVisibleRows = min(CGFloat(items.count), Self.MAX_VISIBLE_ROWS) + let newHeight = items.count == 0 ? + Self.rowsToWindowHeight(for: 1) : // Height for 1 row when empty + Self.rowsToWindowHeight(for: numberOfVisibleRows) + + let currentFrame = window.frame + if isWindowAboveCursor { + // When window is above cursor, maintain the bottom position + let bottomY = currentFrame.minY + let newFrame = NSRect( + x: currentFrame.minX, + y: bottomY, + width: Self.DEFAULT_SIZE.width, + height: newHeight + ) + window.setFrame(newFrame, display: true) + } else { + // When window is below cursor, maintain the top position + window.setContentSize(NSSize(width: Self.DEFAULT_SIZE.width, height: newHeight)) + } + + // Dont allow vertical resizing + window.maxSize = NSSize(width: CGFloat.infinity, height: newHeight) + window.minSize = NSSize(width: Self.DEFAULT_SIZE.width, height: newHeight) + } + + func configureNoItemsLabel() { + window?.contentView?.addSubview(noItemsLabel) + + NSLayoutConstraint.activate([ + noItemsLabel.centerXAnchor.constraint(equalTo: window!.contentView!.centerXAnchor), + noItemsLabel.centerYAnchor.constraint(equalTo: window!.contentView!.centerYAnchor) + ]) + } + + /// Calculate the window height for a given number of rows. + static func rowsToWindowHeight(for numberOfRows: CGFloat) -> CGFloat { + let wholeRows = floor(numberOfRows) + let partialRow = numberOfRows - wholeRows + + let baseHeight = ROW_HEIGHT * wholeRows + let partialHeight = partialRow > 0 ? ROW_HEIGHT * partialRow : 0 + + // Add window padding only for whole numbers + let padding = numberOfRows.truncatingRemainder(dividingBy: 1) == 0 ? WINDOW_PADDING * 2 : WINDOW_PADDING + + return baseHeight + partialHeight + padding + } +} + +extension SuggestionController: NSTableViewDataSource, NSTableViewDelegate { + public func numberOfRows(in tableView: NSTableView) -> Int { + return items.count + } + + public func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + (items[row] as? any CodeSuggestionEntry)?.view + } + + public func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { + CodeSuggestionRowView() + } + + public func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { + // Only allow selection through keyboard navigation or single clicks + let event = NSApp.currentEvent + if event?.type == .leftMouseDragged { + return false + } + return true + } +} + +private class CodeSuggestionRowView: NSTableRowView { + override func drawSelection(in dirtyRect: NSRect) { + guard isSelected else { return } + guard let context = NSGraphicsContext.current?.cgContext else { return } + + context.saveGState() + defer { context.restoreGState() } + + // Create a rect that's inset from the edges and has proper padding + // TODO: We create a new selectionRect instead of using dirtyRect + // because there is a visual bug when holding down the arrow keys + // to select the first or last item, which draws a clipped + // rectangular highlight shape instead of the whole rectangle. + // Replace this when it gets fixed. + let selectionRect = NSRect( + x: SuggestionController.WINDOW_PADDING, + y: 0, + width: bounds.width - (SuggestionController.WINDOW_PADDING * 2), + height: bounds.height + ) + let cornerRadius: CGFloat = 5 + let path = NSBezierPath(roundedRect: selectionRect, xRadius: cornerRadius, yRadius: cornerRadius) + let selectionColor = NSColor.gray.withAlphaComponent(0.19) + + context.setFillColor(selectionColor.cgColor) + path.fill() + } +} diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift new file mode 100644 index 000000000..0ac956f1c --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift @@ -0,0 +1,262 @@ +// +// SuggestionController.swift +// CodeEditTextView +// +// Created by Abe Malla on 6/18/24. +// + +import AppKit +import LanguageServerProtocol + +/// Represents an item that can be displayed in the code suggestion view +public protocol CodeSuggestionEntry { + var view: NSView { get } +} + +public final class SuggestionController: NSWindowController { + + // MARK: - Properties + + public static var DEFAULT_SIZE: NSSize { + NSSize( + width: 256, // TODO: DOES MIN WIDTH DEPEND ON FONT SIZE? + height: rowsToWindowHeight(for: 1) + ) + } + + /// The items to be displayed in the window + public var items: [CompletionItem] = [] { + didSet { onItemsUpdated() } + } + + /// Whether the suggestion window is visbile + public var isVisible: Bool { + window?.isVisible ?? false + } + + public weak var delegate: SuggestionControllerDelegate? + + // MARK: - Private Properties + + /// Height of a single row + static let ROW_HEIGHT: CGFloat = 21 + /// Maximum number of visible rows (8.5) + static let MAX_VISIBLE_ROWS: CGFloat = 8.5 + /// Padding at top and bottom of the window + static let WINDOW_PADDING: CGFloat = 5 + + let tableView = NSTableView() + let scrollView = NSScrollView() + let popover = NSPopover() + /// Tracks when the window is placed above the cursor + var isWindowAboveCursor = false + + let noItemsLabel: NSTextField = { + let label = NSTextField(labelWithString: "No Completions") + label.textColor = .secondaryLabelColor + label.alignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + label.isHidden = false + // TODO: GET FONT SIZE FROM THEME + label.font = .monospacedSystemFont(ofSize: 12, weight: .regular) + return label + }() + + /// An event monitor for keyboard events + private var localEventMonitor: Any? + /// Holds the observer for the window resign notifications + private var windowResignObserver: NSObjectProtocol? + /// Holds the observer for the cursor position update notifications + private var cursorPositionObserver: NSObjectProtocol? + + // MARK: - Initialization + + public init() { + let window = Self.makeWindow() + super.init(window: window) + configureTableView() + configureScrollView() + configureNoItemsLabel() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Opens the window as a child of another window. + public func showWindow(attachedTo parentWindow: NSWindow) { + guard let window = window else { return } + + parentWindow.addChildWindow(window, ordered: .above) + window.orderFront(nil) + + // Close on window switch observer + // Initialized outside of `setupEventMonitors` in order to grab the parent window + if let existingObserver = windowResignObserver { + NotificationCenter.default.removeObserver(existingObserver) + } + windowResignObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: parentWindow, + queue: .main + ) { [weak self] _ in + self?.close() + } + + self.show() + } + + /// Opens the window of items + func show() { + setupEventMonitors() + resetScrollPosition() + super.showWindow(nil) + } + + /// Close the window + public override func close() { + guard isVisible else { return } + removeEventMonitors() + super.close() + } + + private func onItemsUpdated() { + updateSuggestionWindowAndContents() + resetScrollPosition() + tableView.reloadData() + } + + private func setupEventMonitors() { + localEventMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.keyDown, .leftMouseDown, .rightMouseDown] + ) { [weak self] event in + guard let self = self else { return event } + + switch event.type { + case .keyDown: + return checkKeyDownEvents(event) + + case .leftMouseDown, .rightMouseDown: + // If we click outside the window, close the window + if !NSMouseInRect(NSEvent.mouseLocation, self.window!.frame, false) { + self.close() + } + return event + + default: + return event + } + } + + if let existingObserver = cursorPositionObserver { + NotificationCenter.default.removeObserver(existingObserver) + } + cursorPositionObserver = NotificationCenter.default.addObserver( + forName: TextViewController.cursorPositionUpdatedNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self = self, + let textViewController = notification.object as? TextViewController + else { return } + + guard self.isVisible else { return } + self.delegate?.onCursorMove() + } + } + + private func checkKeyDownEvents(_ event: NSEvent) -> NSEvent? { + if !self.isVisible { + return event + } + + switch event.keyCode { + case 53: // Escape + self.close() + return nil + + case 125, 126: // Down/Up Arrow + self.tableView.keyDown(with: event) + guard tableView.selectedRow >= 0 else { return event } + let selectedItem = items[tableView.selectedRow] + self.delegate?.onItemSelect(item: selectedItem) + return nil + + case 124: // Right Arrow +// handleRightArrow() + return event + + case 123: // Left Arrow + return event + + case 36, 48: // Return/Tab + guard tableView.selectedRow >= 0 else { return event } + let selectedItem = items[tableView.selectedRow] + self.delegate?.applyCompletionItem(item: selectedItem) + self.close() + return nil + + default: + return event + } + } + + private func handleRightArrow() { + guard let window = self.window, + let selectedRow = tableView.selectedRowIndexes.first, + selectedRow < items.count, + !popover.isShown else { + return + } + let rowRect = tableView.rect(ofRow: selectedRow) + let rowRectInWindow = tableView.convert(rowRect, to: nil) + let popoverPoint = NSPoint( + x: window.frame.maxX, + y: window.frame.minY + rowRectInWindow.midY + ) + popover.show( + relativeTo: NSRect(x: popoverPoint.x, y: popoverPoint.y, width: 1, height: 1), + of: window.contentView!, + preferredEdge: .maxX + ) + } + + private func resetScrollPosition() { + guard let clipView = scrollView.contentView as? NSClipView else { return } + + // Scroll to the top of the content + clipView.scroll(to: NSPoint(x: 0, y: -Self.WINDOW_PADDING)) + + // Select the first item + if !items.isEmpty { + tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) + } + } + + private func removeEventMonitors() { + if let monitor = localEventMonitor { + NSEvent.removeMonitor(monitor) + localEventMonitor = nil + } + if let observer = windowResignObserver { + NotificationCenter.default.removeObserver(observer) + windowResignObserver = nil + } + if let observer = cursorPositionObserver { + NotificationCenter.default.removeObserver(observer) + cursorPositionObserver = nil + } + } + + deinit { + removeEventMonitors() + } +} + +public protocol SuggestionControllerDelegate: AnyObject { + func applyCompletionItem(item: CompletionItem) + func onClose() + func onCompletion() + func onCursorMove() + func onItemSelect(item: CompletionItem) +} diff --git a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift index 0795c15f3..8cd51d67c 100644 --- a/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Query.swift @@ -16,7 +16,7 @@ extension TreeSitterClient { public struct NodeResult { let id: TreeSitterLanguage let language: Language - let node: Node + public let node: Node } public struct QueryResult { From afc302e6c7afd41364dafced25da248aceff1cd7 Mon Sep 17 00:00:00 2001 From: Abe M Date: Sun, 29 Dec 2024 05:10:37 -0800 Subject: [PATCH 6/7] Small updates --- Package.resolved | 9 +++++++++ .../CodeEditSourceEditor.swift | 10 +++++++--- .../SuggestionController+Window.swift | 9 +++++---- .../CodeSuggestion/SuggestionController.swift | 8 -------- .../SuggestionControllerDelegate.swift | 16 ++++++++++++++++ 5 files changed, 37 insertions(+), 15 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift diff --git a/Package.resolved b/Package.resolved index 76ffd3f8e..7a2ee4bc0 100644 --- a/Package.resolved +++ b/Package.resolved @@ -9,6 +9,15 @@ "version" : "0.1.20" } }, + { + "identity" : "codeedittextview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", + "state" : { + "revision" : "509d7b2e86460e8ec15b0dd5410cbc8e8c05940f", + "version" : "0.7.7" + } + }, { "identity" : "rearrange", "kind" : "remoteSourceControl", diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift index 72a8b2022..c5a6562b6 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift @@ -42,6 +42,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// character's width between characters, etc. Defaults to `1.0` /// - bracketPairHighlight: The type of highlight to use to highlight bracket pairs. /// See `BracketPairHighlight` for more information. Defaults to `nil` + /// - useSystemCursor: If true, uses the system cursor on `>=macOS 14`. /// - undoManager: The undo manager for the text view. Defaults to `nil`, which will create a new CEUndoManager /// - coordinators: Any text coordinators for the view to use. See ``TextViewCoordinator`` for more information. public init( @@ -190,7 +191,6 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { public typealias NSViewControllerType = TextViewController - // TODO: SET COMPLETIONPROVIDER FOR TEXTVIEW public func makeNSViewController(context: Context) -> TextViewController { let controller = TextViewController( string: "", @@ -235,7 +235,6 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { Coordinator(text: text, cursorPositions: cursorPositions) } - // TODO: SET COMPLETIONPROVIDER FOR TEXTVIEW public func updateNSViewController(_ controller: TextViewController, context: Context) { if !context.coordinator.isUpdateFromTextView { // Prevent infinite loop of update notifications @@ -302,6 +301,10 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { controller.letterSpacing = letterSpacing } + if controller.useSystemCursor != useSystemCursor { + controller.useSystemCursor = useSystemCursor + } + controller.bracketPairHighlight = bracketPairHighlight } @@ -322,7 +325,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { controller.indentOption == indentOption && controller.tabWidth == tabWidth && controller.letterSpacing == letterSpacing && - controller.bracketPairHighlight == bracketPairHighlight + controller.bracketPairHighlight == bracketPairHighlight && + controller.useSystemCursor == useSystemCursor } } diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift index 9fb794f90..40fed2bcc 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController+Window.swift @@ -9,7 +9,7 @@ import AppKit extension SuggestionController { /// Will constrain the window's frame to be within the visible screen - public func constrainWindowToScreenEdges(cursorRect: NSRect) { + public func constrainWindowToScreenEdges(cursorRect: NSRect, horizontalOffset: CGFloat) { guard let window = self.window, let screenFrame = window.screen?.visibleFrame else { return @@ -17,9 +17,8 @@ extension SuggestionController { let windowSize = window.frame.size let padding: CGFloat = 22 - // TODO: PASS IN OFFSET var newWindowOrigin = NSPoint( - x: cursorRect.origin.x - Self.WINDOW_PADDING - 13 - 16.5, + x: cursorRect.origin.x - Self.WINDOW_PADDING - horizontalOffset, y: cursorRect.origin.y ) @@ -237,7 +236,8 @@ extension SuggestionController: NSTableViewDataSource, NSTableViewDelegate { } public func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { - (items[row] as? any CodeSuggestionEntry)?.view + guard row >= 0, row < items.count else { return nil } + return (items[row] as? any CodeSuggestionEntry)?.view } public func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { @@ -254,6 +254,7 @@ extension SuggestionController: NSTableViewDataSource, NSTableViewDelegate { } } +/// Used to draw a custom selection highlight for the table row private class CodeSuggestionRowView: NSTableRowView { override func drawSelection(in dirtyRect: NSRect) { guard isSelected else { return } diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift index 0ac956f1c..307f96613 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift @@ -252,11 +252,3 @@ public final class SuggestionController: NSWindowController { removeEventMonitors() } } - -public protocol SuggestionControllerDelegate: AnyObject { - func applyCompletionItem(item: CompletionItem) - func onClose() - func onCompletion() - func onCursorMove() - func onItemSelect(item: CompletionItem) -} diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift new file mode 100644 index 000000000..9c842bbeb --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift @@ -0,0 +1,16 @@ +// +// SuggestionControllerDelegate.swift +// CodeEditSourceEditor +// +// Created by Abe Malla on 12/26/24. +// + +import LanguageServerProtocol + +public protocol SuggestionControllerDelegate: AnyObject { + func applyCompletionItem(item: CompletionItem) + func onClose() + func onCompletion() + func onCursorMove() + func onItemSelect(item: CompletionItem) +} From d1a46045d84eebabbffdb95f9024990e4a5f1d7b Mon Sep 17 00:00:00 2001 From: Abe M Date: Sun, 29 Dec 2024 17:10:12 -0800 Subject: [PATCH 7/7] Replaced CompletionItem type --- .../CodeSuggestion/SuggestionController.swift | 3 +-- .../CodeSuggestion/SuggestionControllerDelegate.swift | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift index 307f96613..3c2028983 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionController.swift @@ -6,7 +6,6 @@ // import AppKit -import LanguageServerProtocol /// Represents an item that can be displayed in the code suggestion view public protocol CodeSuggestionEntry { @@ -25,7 +24,7 @@ public final class SuggestionController: NSWindowController { } /// The items to be displayed in the window - public var items: [CompletionItem] = [] { + public var items: [CodeSuggestionEntry] = [] { didSet { onItemsUpdated() } } diff --git a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift index 9c842bbeb..0abf92470 100644 --- a/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift +++ b/Sources/CodeEditSourceEditor/CodeSuggestion/SuggestionControllerDelegate.swift @@ -5,12 +5,10 @@ // Created by Abe Malla on 12/26/24. // -import LanguageServerProtocol - public protocol SuggestionControllerDelegate: AnyObject { - func applyCompletionItem(item: CompletionItem) + func applyCompletionItem(item: CodeSuggestionEntry) func onClose() func onCompletion() func onCursorMove() - func onItemSelect(item: CompletionItem) + func onItemSelect(item: CodeSuggestionEntry) }