Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -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")
}
14 changes: 14 additions & 0 deletions Sources/CodeEditTextView/Extensions/NSPoint+Rounded.swift
Original file line numberDiff line numberDiff line change
@@ -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())
}
}
Original file line numberDiff line numberDiff line change
@@ -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
)
)
}
}
Original file line numberDiff line numberDiff line change
@@ -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
}
}
}
Original file line numberDiff line numberDiff line change
@@ -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
}
}
Original file line numberDiff line numberDiff line change
@@ -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)])
}
}
Original file line numberDiff line numberDiff line change
@@ -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
)
}
}
Original file line numberDiff line numberDiff line change
@@ -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
}
}
Loading