Skip to content

Column Selection #107

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 23, 2025
Merged
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
@@ -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)
}
}

@@ -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:
Original file line number Diff line number Diff line change
@@ -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
@@ -127,6 +128,7 @@ public class TextSelectionManager: NSObject {
}
if !didHandle {
textSelections.append(newTextSelection)
textSelections.sort(by: { $0.range.location < $1.range.location })
}

updateSelectionViews()
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
@@ -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
@@ -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
@@ -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 {
@@ -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()
@@ -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() {
@@ -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
@@ -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
}