Skip to content
Merged
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 number Diff line number Diff line change
Expand Up @@ -75,14 +75,22 @@ extension TextLayoutManager {
) else {
return nil
}
let fragment = fragmentPosition.data

return textOffsetAtPoint(point, fragmentPosition: fragmentPosition, linePosition: linePosition)
}

func textOffsetAtPoint(
_ point: CGPoint,
fragmentPosition: TextLineStorage<LineFragment>.TextLinePosition,
linePosition: TextLineStorage<TextLine>.TextLinePosition
) -> Int? {
let fragment = fragmentPosition.data
if fragment.width == 0 {
return linePosition.range.location + fragmentPosition.range.location
} else if fragment.width <= point.x - edgeInsets.left {
return findOffsetAfterEndOf(fragmentPosition: fragmentPosition, in: linePosition)
} else {
return findOffsetAtPoint(inFragment: fragment, point: point, inLine: linePosition)
return findOffsetAtPoint(inFragment: fragment, xPos: point.x, inLine: linePosition)
}
}

Expand Down Expand Up @@ -125,23 +133,23 @@ extension TextLayoutManager {
/// Finds a document offset for a point that lies in a line fragment.
/// - Parameters:
/// - fragment: The fragment the point lies in.
/// - point: The point being queried, relative to the text view.
/// - xPos: The point being queried, relative to the text view.
/// - linePosition: The position that contains the `fragment`.
/// - Returns: The offset (relative to the document) that's closest to the given point, or `nil` if it could not be
/// found.
private func findOffsetAtPoint(
func findOffsetAtPoint(
inFragment fragment: LineFragment,
point: CGPoint,
xPos: CGFloat,
inLine linePosition: TextLineStorage<TextLine>.TextLinePosition
) -> Int? {
guard let (content, contentPosition) = fragment.findContent(atX: point.x - edgeInsets.left) else {
guard let (content, contentPosition) = fragment.findContent(atX: xPos - edgeInsets.left) else {
return nil
}
switch content.data {
case .text(let ctLine):
let fragmentIndex = CTLineGetStringIndexForPosition(
ctLine,
CGPoint(x: point.x - edgeInsets.left - contentPosition.xPos, y: fragment.height/2)
CGPoint(x: xPos - edgeInsets.left - contentPosition.xPos, y: fragment.height/2)
)
return fragmentIndex + contentPosition.offset + linePosition.range.location
case .attachment:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ public class TextSelectionManager: NSObject {
(0...(textStorage?.length ?? 0)).contains($0.location)
&& (0...(textStorage?.length ?? 0)).contains($0.max)
}
.sorted(by: { $0.location < $1.location })
.map {
let selection = TextSelection(range: $0)
selection.suggestedXPos = layoutManager?.rectForOffset($0.location)?.minX
Expand Down Expand Up @@ -127,6 +128,7 @@ public class TextSelectionManager: NSObject {
}
if !didHandle {
textSelections.append(newTextSelection)
textSelections.sort(by: { $0.range.location < $1.range.location })
}

updateSelectionViews()
Expand Down
50 changes: 50 additions & 0 deletions Sources/CodeEditTextView/TextView/TextView+ColumnSelection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//
// TextView+ColumnSelection.swift
// CodeEditTextView
//
// Created by Khan Winter on 6/19/25.
//

import AppKit

extension TextView {
/// Set the user's selection to a square region in the editor.
///
/// This method will automatically determine a valid region from the provided two points.
/// - Parameters:
/// - pointA: The first point.
/// - pointB: The second point.
public func selectColumns(betweenPointA pointA: CGPoint, pointB: CGPoint) {
let start = CGPoint(x: min(pointA.x, pointB.x), y: min(pointA.y, pointB.y))
let end = CGPoint(x: max(pointA.x, pointB.x), y: max(pointA.y, pointB.y))

// Collect all overlapping text ranges
var selectedRanges: [NSRange] = layoutManager.linesStartingAt(start.y, until: end.y).flatMap { textLine in
// Collect fragment ranges
return textLine.data.lineFragments.compactMap { lineFragment -> NSRange? in
let startOffset = self.layoutManager.textOffsetAtPoint(
start,
fragmentPosition: lineFragment,
linePosition: textLine
)
let endOffset = self.layoutManager.textOffsetAtPoint(
end,
fragmentPosition: lineFragment,
linePosition: textLine
)
guard let startOffset, let endOffset else { return nil }

return NSRange(start: startOffset, end: endOffset)
}
}

// If we have some non-cursor selections, filter out any cursor selections
if selectedRanges.contains(where: { !$0.isEmpty }) {
selectedRanges = selectedRanges.filter({
!$0.isEmpty || (layoutManager.rectForOffset($0.location)?.origin.x.approxEqual(start.x) ?? false)
})
}

selectionManager.setSelectedRanges(selectedRanges)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ extension TextView {
open override func resetCursorRects() {
super.resetCursorRects()
if isSelectable {
addCursorRect(visibleRect, cursor: .iBeam)
addCursorRect(
visibleRect,
cursor: isOptionPressed ? .crosshair : .iBeam
)
}
}
}
12 changes: 12 additions & 0 deletions Sources/CodeEditTextView/TextView/TextView+KeyDown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,16 @@ extension TextView {

return false
}

override public func flagsChanged(with event: NSEvent) {
super.flagsChanged(with: event)

let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
let modifierFlagsIsOption = modifierFlags == [.option]

if modifierFlagsIsOption != isOptionPressed {
isOptionPressed = modifierFlagsIsOption
resetCursorRects()
}
}
}
92 changes: 56 additions & 36 deletions Sources/CodeEditTextView/TextView/TextView+Mouse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,11 @@ extension TextView {
super.mouseDown(with: event)
return
}
if event.modifierFlags.intersection(.deviceIndependentFlagsMask).isSuperset(of: [.control, .shift]) {
let eventFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if eventFlags == [.control, .shift] {
unmarkText()
selectionManager.addSelectedRange(NSRange(location: offset, length: 0))
} else if event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.shift) {
} else if eventFlags.contains(.shift) {
unmarkText()
shiftClickExtendSelection(to: offset)
} else {
Expand Down Expand Up @@ -96,40 +97,11 @@ extension TextView {
return
}

switch cursorSelectionMode {
case .character:
selectionManager.setSelectedRange(
NSRange(
location: min(startPosition, endPosition),
length: max(startPosition, endPosition) - min(startPosition, endPosition)
)
)

case .word:
let startWordRange = findWordBoundary(at: startPosition)
let endWordRange = findWordBoundary(at: endPosition)

selectionManager.setSelectedRange(
NSRange(
location: min(startWordRange.location, endWordRange.location),
length: max(startWordRange.location + startWordRange.length,
endWordRange.location + endWordRange.length) -
min(startWordRange.location, endWordRange.location)
)
)

case .line:
let startLineRange = findLineBoundary(at: startPosition)
let endLineRange = findLineBoundary(at: endPosition)

selectionManager.setSelectedRange(
NSRange(
location: min(startLineRange.location, endLineRange.location),
length: max(startLineRange.location + startLineRange.length,
endLineRange.location + endLineRange.length) -
min(startLineRange.location, endLineRange.location)
)
)
let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if modifierFlags.contains(.option) {
dragColumnSelection(mouseDragAnchor: mouseDragAnchor, event: event)
} else {
dragSelection(startPosition: startPosition, endPosition: endPosition, mouseDragAnchor: mouseDragAnchor)
}

setNeedsDisplay()
Expand Down Expand Up @@ -164,6 +136,8 @@ extension TextView {
setNeedsDisplay()
}

// MARK: - Mouse Autoscroll

/// Sets up a timer that fires at a predetermined period to autoscroll the text view.
/// Ensure the timer is disabled using ``disableMouseAutoscrollTimer``.
func setUpMouseAutoscrollTimer() {
Expand All @@ -182,4 +156,50 @@ extension TextView {
mouseDragTimer?.invalidate()
mouseDragTimer = nil
}

// MARK: - Drag Selection

private func dragSelection(startPosition: Int, endPosition: Int, mouseDragAnchor: CGPoint) {
switch cursorSelectionMode {
case .character:
selectionManager.setSelectedRange(
NSRange(
location: min(startPosition, endPosition),
length: max(startPosition, endPosition) - min(startPosition, endPosition)
)
)

case .word:
let startWordRange = findWordBoundary(at: startPosition)
let endWordRange = findWordBoundary(at: endPosition)

selectionManager.setSelectedRange(
NSRange(
location: min(startWordRange.location, endWordRange.location),
length: max(startWordRange.location + startWordRange.length,
endWordRange.location + endWordRange.length) -
min(startWordRange.location, endWordRange.location)
)
)

case .line:
let startLineRange = findLineBoundary(at: startPosition)
let endLineRange = findLineBoundary(at: endPosition)

selectionManager.setSelectedRange(
NSRange(
location: min(startLineRange.location, endLineRange.location),
length: max(startLineRange.location + startLineRange.length,
endLineRange.location + endLineRange.length) -
min(startLineRange.location, endLineRange.location)
)
)
}
}

private func dragColumnSelection(mouseDragAnchor: CGPoint, event: NSEvent) {
// Drag the selection and select in columns
let eventLocation = convert(event.locationInWindow, from: nil)
selectColumns(betweenPointA: eventLocation, pointB: mouseDragAnchor)
}
}
2 changes: 2 additions & 0 deletions Sources/CodeEditTextView/TextView/TextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,8 @@ public class TextView: NSView, NSTextContent {
var draggingCursorView: NSView?
var isDragging: Bool = false

var isOptionPressed: Bool = false

private var fontCharWidth: CGFloat {
(" " as NSString).size(withAttributes: [.font: font]).width
}
Expand Down