diff --git a/Sources/CodeEditTextView/Extensions/NSAttributedString.Key+CaptureName.swift b/Sources/CodeEditTextView/Extensions/NSAttributedString.Key+CaptureName.swift new file mode 100644 index 000000000..42d9bdb5d --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/NSAttributedString.Key+CaptureName.swift @@ -0,0 +1,12 @@ +// +// NSAttributedString.Key+CaptureName.swift +// CodeEditSourceEditor +// +// Created by Daniel Choroszucha on 09/03/2025. +// + +import Foundation + +public extension NSAttributedString.Key { + static let captureName = NSAttributedString.Key("captureName") +} diff --git a/Sources/CodeEditTextView/Extensions/NSPoint+Rounded.swift b/Sources/CodeEditTextView/Extensions/NSPoint+Rounded.swift new file mode 100644 index 000000000..6fbf14085 --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/NSPoint+Rounded.swift @@ -0,0 +1,14 @@ +// +// NSPoint+Rounded.swift +// CodeEditTextView +// +// Created by Daniel Choroszucha on 09/03/2025. +// + +import Foundation + +extension NSPoint { + var rounded: NSPoint { + return NSPoint(x: x.rounded(), y: y.rounded()) + } +} diff --git a/Sources/CodeEditTextView/SyntacticTextSelectionManager/SyntacticTextSelectionManager.swift b/Sources/CodeEditTextView/SyntacticTextSelectionManager/SyntacticTextSelectionManager.swift new file mode 100644 index 000000000..4a1196095 --- /dev/null +++ b/Sources/CodeEditTextView/SyntacticTextSelectionManager/SyntacticTextSelectionManager.swift @@ -0,0 +1,37 @@ +// +// SyntacticTextSelectionManager.swift +// CodeEditTextView +// +// Created by Daniel Choroszucha on 23/03/2025. +// + +import Foundation + +public class SyntacticTextSelectionManager: TextSelectionManager { + public static let syntacticCategorySelectionChangedNotification: Notification.Name = .init("com.CodeEdit.SyntacticTextSelectionManager.Syntactic CategorySelectionChangedNotification") + + public var selectedSyntacticName: String? + public var selectedSyntacticRange: NSRange? + + /// - Parameter offset: text position + func setSelectedCapture( + _ captureName: String?, + at range: NSRange? + ) { + selectedSyntacticName = captureName + selectedSyntacticRange = range + + if let range { + setSelectedRanges([range]) + } else { + setSelectedRanges([]) + } + + NotificationCenter.default.post( + Notification( + name: Self.syntacticCategorySelectionChangedNotification, + object: self + ) + ) + } +} diff --git a/Sources/CodeEditTextView/SyntacticTextView/SyntacticTextView+Attributes.swift b/Sources/CodeEditTextView/SyntacticTextView/SyntacticTextView+Attributes.swift new file mode 100644 index 000000000..bcff4db90 --- /dev/null +++ b/Sources/CodeEditTextView/SyntacticTextView/SyntacticTextView+Attributes.swift @@ -0,0 +1,29 @@ +// +// SyntacticTextView+Attributes.swift +// CodeEditTextView +// +// Created by Daniel Choroszucha on 09/03/2025. +// + +import Foundation + +extension SyntacticTextView { + func attributes( + at location: Int, + effectiveRange range: NSRangePointer? + ) -> [NSAttributedString.Key: Any] { + var attributes = textStorage.attributes(at: location, effectiveRange: range) + + // substring at location + let substring = textStorage.attributedSubstring(from: .init(location: location, length: 1)).string + if substring.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false, + attributes[.captureName] as? String == nil { + // TODO: [23.03.2025] Handle default capture name - + /// https://linear.app/codetheme/issue/MAC-25/handle-defaults-for-attribute-to-capture-mapping-failure + attributes[.captureName] = "parameter" + return attributes + } else { + return attributes + } + } +} diff --git a/Sources/CodeEditTextView/SyntacticTextView/SyntacticTextView+Capture.swift b/Sources/CodeEditTextView/SyntacticTextView/SyntacticTextView+Capture.swift new file mode 100644 index 000000000..e86cc192d --- /dev/null +++ b/Sources/CodeEditTextView/SyntacticTextView/SyntacticTextView+Capture.swift @@ -0,0 +1,157 @@ +// +// SyntacticTextView+Capture.swift +// CodeEditTextView +// +// Created by Daniel Choroszucha on 23/03/2025. +// + +import Foundation + +extension SyntacticTextView { + private func rangeForSelectedCapture() throws -> NSRange { + let range = selectionManager.textSelections.compactMap { textSelection -> NSRange? in + let attributedSubstring = textStorage.attributedSubstring( + from: NSRange(location: textSelection.range.location, length: 1) + ) + + guard textSelection.range.isEmpty, + let char = attributedSubstring.string.first + else { + return nil + } + + guard + let characterSet = characterSet(for: String(char)) + else { + return nil + } + + if characterSet == .alphanumerics || characterSet == .punctuationCharacters { + guard + let start = textStorage.findPrecedingOccurrenceOfCharacter( + in: characterSet.inverted, + from: textSelection.range.location + ), + let end = textStorage.findNextOccurrenceOfCharacter( + in: characterSet.inverted, + from: textSelection.range.max + ) + else { + return nil + } + return NSRange(start: start, end: end) + } else { + return nil + } + } + guard let first = range.first else { throw CaptureSelectionError.unknown } + return first + } + + private func characterSet(for string: String) -> CharacterSet? { + let charSet = CharacterSet(charactersIn: string) + + if CharacterSet.alphanumerics.isSuperset(of: charSet) { + return .alphanumerics + } else if CharacterSet.whitespaces.isSuperset(of: charSet) { + return .whitespaces + } else if CharacterSet.newlines.isSuperset(of: charSet) { + return .newlines + } else if CharacterSet.punctuationCharacters.isSuperset(of: charSet) { + return .punctuationCharacters + } else { + return nil + } + } + + enum CaptureSelectionError: Error { + case empty + case outOfBounds + case invisibles + case missingAttribute + case unknown + } + + func selectCapture(_ sender: Any?) { + // TODO: [09.03.2025] Add selection padding - + /// to check leading / trailing characters next to current selection + /// and add logic that verifies that this is between the same capture sytnax and should be highlighted + /// eg. comments, docs + guard textStorage.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { return } + + do { + let hoveredRange = try rangeForSelectedCapture() + let captureName = try captureName(for: hoveredRange) + currentlyHoveredCaptureName = captureName + + let documentRange = visibleRange + let matchingRanges = ranges(for: captureName, in: documentRange) + selectionManager.setSelectedRanges(matchingRanges) + // TODO: [23.03.2025] Efficiency issue: many repeating calls - + syntacticSelectionManager.setSelectedCapture(captureName, at: hoveredRange) + print("Hovered range: \(hoveredRange)") + unmarkTextIfNeeded() + needsDisplay = true + } catch { + deselectCapture() + } + } + + func deselectCapture() { + currentlyHoveredCaptureName = nil + selectionManager.removeCursors() + selectionManager.setSelectedRanges([]) + syntacticSelectionManager.setSelectedCapture(nil, at: nil) + unmarkTextIfNeeded() + needsDisplay = true + } + + private func ranges(for captureName: String, in range: NSRange) -> [NSRange] { + var matchingRanges: [NSRange] = [] + var searchRange = range + + while searchRange.length > 0 { + var foundRange = NSRange(location: NSNotFound, length: 0) + + textStorage.enumerateAttribute( + .captureName, + in: searchRange, + options: [] + ) { value, currentRange, stop in + if let value = value as? String, value == captureName, + textStorage.substring(from: currentRange)?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + foundRange = currentRange + stop.pointee = true + } + } + + if foundRange.location != NSNotFound { + matchingRanges.append(foundRange) + + // Update search range correctly + let newStart = foundRange.location + foundRange.length + let remainingLengthInSearchRange = searchRange.location + searchRange.length - newStart + let newSearchRange = NSRange(location: newStart, length: remainingLengthInSearchRange) + searchRange = newSearchRange + } else { + break + } + } + + return matchingRanges + } + + private func captureName(for range: NSRange) throws -> String { + let range = try rangeForSelectedCapture() + var effectiveRange = NSRange(start: 0, end: 0) + let attributes = attributes( + at: range.lowerBound, + effectiveRange: &effectiveRange + ) + guard let value = attributes[.captureName] as? String + else { + throw CaptureSelectionError.missingAttribute + } + return value + } +} diff --git a/Sources/CodeEditTextView/SyntacticTextView/SyntacticTextView+Mouse.swift b/Sources/CodeEditTextView/SyntacticTextView/SyntacticTextView+Mouse.swift new file mode 100644 index 000000000..5f1626420 --- /dev/null +++ b/Sources/CodeEditTextView/SyntacticTextView/SyntacticTextView+Mouse.swift @@ -0,0 +1,69 @@ +// +// SyntacticTextView+Mouse.swift +// CodeEditTextView +// +// Created by Daniel Choroszucha on 23/03/2025. +// + +import AppKit + +extension SyntacticTextView { + public override func mouseDown(with event: NSEvent) { + guard let offset = offset(for: event) else { return } + handleSingleClick(event: event, offset: offset) + } + + private func offset(for event: NSEvent) -> Int? { + layoutManager.textOffsetAtPoint( + convert( + event.locationInWindow, + from: nil + ) + ) + } + + fileprivate func handleSingleClick(event: NSEvent, offset: Int) { + selectionManager.setSelectedRange(NSRange(location: offset, length: 0)) + selectCapture(nil) + unmarkTextIfNeeded() + } +} + +// MARK: Mouse Hover - + +public extension SyntacticTextView { + override func mouseEntered(with event: NSEvent) { + if let location = updatedLocation(for: event), + let offset = layoutManager.textOffsetAtPoint(convert(location, from: nil)) { + handleHover(at: offset) + } + } + + override func mouseMoved(with event: NSEvent) { + if let location = updatedLocation(for: event), + let offset = layoutManager.textOffsetAtPoint(convert(location, from: nil)) { + handleHover(at: offset) + } + } + + override func mouseExited(with event: NSEvent) { + roundedPreviousMousePosition = nil + } + + private func updatedLocation(for event: NSEvent) -> NSPoint? { + let newLocation = event.locationInWindow.rounded + let previousLocation = roundedPreviousMousePosition + + /// Early return when update is not required + guard newLocation.x != previousLocation?.x || newLocation.y != previousLocation?.y else { + return nil + } + + roundedPreviousMousePosition = newLocation + return newLocation + } + + private func handleHover(at offset: Int) { + selectionManager.setSelectedRanges([NSRange(location: offset, length: 0)]) + } +} diff --git a/Sources/CodeEditTextView/SyntacticTextView/SyntacticTextView+TrackingArea.swift b/Sources/CodeEditTextView/SyntacticTextView/SyntacticTextView+TrackingArea.swift new file mode 100644 index 000000000..7e2a3d276 --- /dev/null +++ b/Sources/CodeEditTextView/SyntacticTextView/SyntacticTextView+TrackingArea.swift @@ -0,0 +1,49 @@ +// +// SyntacticTextView+TrackingArea.swift +// CodeEditTextView +// +// Created by Daniel Choroszucha on 02/03/2025. +// + +import AppKit + +extension SyntacticTextView { + // MARK: - Tracking Area Management + + override public func updateTrackingAreas() { + super.updateTrackingAreas() + updateHoverTrackingAreas() + } + + private func updateHoverTrackingAreas() { + removeExistingTrackingAreas() + addTrackingAreasForLineFragments() + } + + private func removeExistingTrackingAreas() { + trackingAreas.forEach { removeTrackingArea($0) } + } + + private func addTrackingAreasForLineFragments() { + guard + let layoutManager = layoutManager + else { + return + } + + layoutManager.layoutView?.subviews.forEach { subview in + guard let lineFragmentView = subview as? LineFragmentView else { return } + let trackingArea = createTrackingArea(for: lineFragmentView.frame) + addTrackingArea(trackingArea) + } + } + + private func createTrackingArea(for rect: NSRect) -> NSTrackingArea { + return NSTrackingArea( + rect: rect, + options: [.mouseEnteredAndExited, .mouseMoved, .activeInKeyWindow], + owner: self, + userInfo: nil + ) + } +} diff --git a/Sources/CodeEditTextView/SyntacticTextView/SyntacticTextView+VisibleRange.swift b/Sources/CodeEditTextView/SyntacticTextView/SyntacticTextView+VisibleRange.swift new file mode 100644 index 000000000..ed973b594 --- /dev/null +++ b/Sources/CodeEditTextView/SyntacticTextView/SyntacticTextView+VisibleRange.swift @@ -0,0 +1,16 @@ +// +// SyntacticTextView+VisibleRange.swift +// CodeEditTextView +// +// Created by Daniel Choroszucha on 09/03/2025. +// + +import Foundation + +extension SyntacticTextView { + // TODO: [09.03.2025] Calculate visibile range - + /// https://linear.app/codetheme/issue/MAC-26/dynamically-calculate-visible-text-range-in-window + var visibleRange: NSRange { + documentRange + } +} diff --git a/Sources/CodeEditTextView/SyntacticTextView/SyntacticTextView.swift b/Sources/CodeEditTextView/SyntacticTextView/SyntacticTextView.swift new file mode 100644 index 000000000..89f58c7f1 --- /dev/null +++ b/Sources/CodeEditTextView/SyntacticTextView/SyntacticTextView.swift @@ -0,0 +1,49 @@ +// +// SyntacticTextView.swift +// CodeEditTextView +// +// Created by Daniel Choroszucha on 23/03/2025. +// + +import AppKit + +public class SyntacticTextView: TextView { + /// The syntax category selection manager for the syntactic text view. + public package(set) var syntacticSelectionManager: SyntacticTextSelectionManager! + + var roundedPreviousMousePosition: NSPoint? + var currentlyHoveredCaptureName: String? + + public init(string: String) { + super.init( + string: string, + isEditable: false + ) + self.syntacticSelectionManager = setUpSyntacticSelectionManager() + } + + @MainActor required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func layout() { + super.layout() + updateTrackingAreas() + } + + override public func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + updateTrackingAreas() + } +} + +extension SyntacticTextView { + func setUpSyntacticSelectionManager() -> SyntacticTextSelectionManager { + SyntacticTextSelectionManager( + layoutManager: layoutManager, + textStorage: textStorage, + textView: self, + delegate: self + ) + } +}