Skip to content

Commit

Permalink
Handle drag gesture
Browse files Browse the repository at this point in the history
  • Loading branch information
krzyzanowskim committed Jun 26, 2023
1 parent ec9245c commit c10463d
Show file tree
Hide file tree
Showing 10 changed files with 166 additions and 65 deletions.
26 changes: 20 additions & 6 deletions Sources/STTextView/Extensions/NSTextLayoutManager+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ extension NSTextLayoutManager {
}

func textSelectionsAttributedString() -> NSAttributedString? {
let attributedString = textSelections.flatMap(\.textRanges).reduce(NSMutableAttributedString()) { partialResult, range in
textAttributedString(in: textSelections.flatMap(\.textRanges))
}

func textAttributedString(in textRanges: [NSTextRange]) -> NSAttributedString? {
let attributedString = textRanges.reduce(NSMutableAttributedString()) { partialResult, range in
if let attributedString = textContentManager?.attributedString(in: range) {
if partialResult.length != 0 {
partialResult.append(NSAttributedString(string: "\n"))
Expand All @@ -58,11 +62,11 @@ extension NSTextLayoutManager {
}

/// A text segment is both logically and visually contiguous portion of the text content inside a line fragment.
public func textSelectionSegmentFrame(at location: NSTextLocation, type: NSTextLayoutManager.SegmentType) -> CGRect? {
textSelectionSegmentFrame(in: NSTextRange(location: location), type: type)
public func textSegmentFrame(at location: NSTextLocation, type: NSTextLayoutManager.SegmentType) -> CGRect? {
textSegmentFrame(in: NSTextRange(location: location), type: type)
}

public func textSelectionSegmentFrame(in textRange: NSTextRange, type: NSTextLayoutManager.SegmentType) -> CGRect? {
public func textSegmentFrame(in textRange: NSTextRange, type: NSTextLayoutManager.SegmentType) -> CGRect? {
var result: CGRect? = nil
// .upstreamAffinity: When specified, the segment is placed based on the upstream affinity for an empty range.
//
Expand All @@ -80,15 +84,25 @@ extension NSTextLayoutManager {
return result
}

public func textLineFragment(at location: NSTextLocation) -> NSTextLineFragment? {
func textLineFragment(at location: NSTextLocation) -> NSTextLineFragment? {
textLayoutFragment(for: location)?.textLineFragment(at: location)
}

public func textLineFragment(at point: CGPoint) -> NSTextLineFragment? {
func textLineFragment(at point: CGPoint) -> NSTextLineFragment? {
textLayoutFragment(for: point)?.textLineFragment(at: point)
}

@discardableResult
func enumerateTextLayoutFragments(in range: NSTextRange, options: NSTextLayoutFragment.EnumerationOptions = [], using block: (NSTextLayoutFragment) -> Bool) -> NSTextLocation? {
enumerateTextLayoutFragments(from: range.location, options: options) { layoutFragment in
let shouldContinue = layoutFragment.rangeInElement.location <= range.endLocation
if !shouldContinue {
return false
}

return shouldContinue && block(layoutFragment)
}
}

}

Expand Down
2 changes: 1 addition & 1 deletion Sources/STTextView/STTextView+Complete.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ extension STTextView {
@MainActor
private func performCompletion() {
guard let insertionPointLocation = textLayoutManager.insertionPointLocations.first,
let textCharacterSegmentRect = textLayoutManager.textSelectionSegmentFrame(at: insertionPointLocation, type: .standard)
let textCharacterSegmentRect = textLayoutManager.textSegmentFrame(at: insertionPointLocation, type: .standard)
else {
self.completionWindowController.close()
return
Expand Down
54 changes: 54 additions & 0 deletions Sources/STTextView/STTextView+DragGestureRecognizer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Created by Marcin Krzyzanowski
// https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md

import Cocoa

extension STTextView {

func dragSelectedTextGestureRecognizer() -> NSGestureRecognizer {
let recognizer = NSPressGestureRecognizer(target: self, action: #selector(_dragSelectedTextGestureRecognizer(gestureRecognizer:)))
recognizer.minimumPressDuration = NSEvent.doubleClickInterval / 3
recognizer.delaysPrimaryMouseButtonEvents = true
recognizer.isEnabled = isSelectable
return recognizer
}

/// Gesture action for press and drag selected
@objc private func _dragSelectedTextGestureRecognizer(gestureRecognizer: NSGestureRecognizer) {
guard gestureRecognizer.state == .began else {
return
}

let eventPoint = gestureRecognizer.location(in: self)
let currentSelectionRanges = textLayoutManager.textSelectionsRanges(.withoutInsertionPoints)

guard !currentSelectionRanges.isEmpty else {
return
}

lazy var interactionInSelectedRange: Bool = {
currentSelectionRanges.reduce(true) { partialResult, range in
guard let interationLocation = textLayoutManager.location(interactingAt: eventPoint, inContainerAt: range.location) else {
return partialResult
}
return partialResult && range.contains(interationLocation)
}
}()

// TODO: loop over all selected ranges
guard interactionInSelectedRange, let selectionsAttributedString = textLayoutManager.textSelectionsAttributedString(), let textRange = currentSelectionRanges.first else {
return
}

let rangeView = TextLayoutRangeView(textLayoutManager: textLayoutManager, textRange: textRange)
let imageRep = bitmapImageRepForCachingDisplay(in: rangeView.bounds)!
rangeView.cacheDisplay(in: rangeView.bounds, to: imageRep)

let draggingImage = NSImage(cgImage: imageRep.cgImage!, size: rangeView.bounds.size)

let draggingItem = NSDraggingItem(pasteboardWriter: selectionsAttributedString)
draggingItem.setDraggingFrame(rangeView.frame, contents: draggingImage)

draggingSession = beginDraggingSession(with: [draggingItem], event: NSApp.currentEvent!, source: self)
}
}
89 changes: 62 additions & 27 deletions Sources/STTextView/STTextView+Mouse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,49 @@ extension STTextView {
return
}

var handled = false
if event.clickCount == 1 {
let point = convert(event.locationInWindow, from: nil)
let handled: Bool

switch event.clickCount {
case 1:
let eventPoint = convert(event.locationInWindow, from: nil)
// let currentSelectionRanges = textLayoutManager.textSelectionsRanges(.withoutInsertionPoints)
//
// lazy var interactionInSelectedRange: Bool = {
// currentSelectionRanges.reduce(true) { partialResult, range in
// guard let interationLocation = textLayoutManager.location(interactingAt: eventPoint, inContainerAt: range.location) else {
// return partialResult
// }
// return partialResult && range.contains(interationLocation)
// }
// }()
//
// if !currentSelectionRanges.isEmpty,
// interactionInSelectedRange,
// let selectionsAttributedString = textLayoutManager.textSelectionsAttributedString(),
// let textRange = currentSelectionRanges.first // TODO: loop over ranges
// {
// // has selection, and tap on the selected area
// // therefore start dragging session. dragging is interrupted
// // by mouseup event, or any other mouse event
// let rangeView = TextLayoutRangeView(textLayoutManager: textLayoutManager, textRange: textRange)
// let imageRep = bitmapImageRepForCachingDisplay(in: rangeView.bounds)!
// rangeView.cacheDisplay(in: rangeView.bounds, to: imageRep)
//
// let draggingImage = NSImage(cgImage: imageRep.cgImage!, size: rangeView.bounds.size)
//
// let draggingItem = NSDraggingItem(pasteboardWriter: selectionsAttributedString)
// draggingItem.setDraggingFrame(rangeView.frame, contents: draggingImage)
//
// beginDraggingSession(with: [draggingItem], event: event, source: self)
// } else
if event.modifierFlags.isSuperset(of: [.control, .shift]) {
textLayoutManager.appendInsertionPointSelection(interactingAt: point)
textLayoutManager.appendInsertionPointSelection(interactingAt: eventPoint)
updateTypingAttributes()
updateSelectionHighlights()
needsDisplay = true
} else {
updateTextSelection(
interactingAt: point,
interactingAt: eventPoint,
inContainerAt: textLayoutManager.documentRange.location,
anchors: event.modifierFlags.contains(.shift) ? textLayoutManager.textSelections : [],
extending: event.modifierFlags.contains(.shift),
Expand All @@ -34,12 +66,14 @@ extension STTextView {
)
}
handled = true
} else if event.clickCount == 2 {
case 2:
selectWord(self)
handled = true
} else if event.clickCount == 3 {
selectLine(self)
case 3:
selectParagraph(self)
handled = true
default:
handled = false
}

if !handled {
Expand All @@ -61,27 +95,28 @@ extension STTextView {
return
}

if isSelectable, event.type == .leftMouseDragged, (!event.deltaY.isZero || !event.deltaX.isZero) {
let point = convert(event.locationInWindow, from: nil)
guard isSelectable, (!event.deltaY.isZero || !event.deltaX.isZero) else {
super.mouseDragged(with: event)
return
}

if mouseDraggingSelectionAnchors == nil {
mouseDraggingSelectionAnchors = textLayoutManager.textSelections
}
let eventPoint = convert(event.locationInWindow, from: nil)

updateTextSelection(
interactingAt: point,
inContainerAt: mouseDraggingSelectionAnchors?.first?.textRanges.first?.location ?? textLayoutManager.documentRange.location,
anchors: mouseDraggingSelectionAnchors!,
extending: true,
isDragging: true,
visual: event.modifierFlags.contains(.option)
)

if autoscroll(with: event) {
// TODO: periodic repeat this event, until don't
}
} else {
super.mouseDragged(with: event)
if mouseDraggingSelectionAnchors == nil {
mouseDraggingSelectionAnchors = textLayoutManager.textSelections
}

updateTextSelection(
interactingAt: eventPoint,
inContainerAt: mouseDraggingSelectionAnchors?.first?.textRanges.first?.location ?? textLayoutManager.documentRange.location,
anchors: mouseDraggingSelectionAnchors!,
extending: true,
isDragging: true,
visual: event.modifierFlags.contains(.option)
)

if autoscroll(with: event) {
// TODO: periodic repeat this event, until don't
}
}

Expand Down
7 changes: 0 additions & 7 deletions Sources/STTextView/STTextView+NSDraggingDestination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,6 @@ extension STTextView {
open override func concludeDragOperation(_ sender: NSDraggingInfo?) {
super.concludeDragOperation(sender)
cleanUpAfterDragOperation()
updateInsertionPointStateAndRestartTimer()
displayIfNeeded()
}

open override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
logger.debug("drag operation")
return super.performDragOperation(sender)
}

}
10 changes: 1 addition & 9 deletions Sources/STTextView/STTextView+NSDraggingSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,8 @@ extension STTextView: NSDraggingSource {
context == .outsideApplication ? .copy : .move
}

public func draggingSession(_ session: NSDraggingSession, willBeginAt screenPoint: NSPoint) {
logger.debug("\(#function)")
}

public func draggingSession(_ session: NSDraggingSession, movedTo screenPoint: NSPoint) {
logger.debug("\(#function), screenPoint: \(screenPoint.debugDescription)")
}

public func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) {
logger.debug("\(#function), screenPoint: \(screenPoint.debugDescription), operation: \(operation.rawValue)")
cleanUpAfterDragOperation()
self.draggingSession = nil
}
}
2 changes: 1 addition & 1 deletion Sources/STTextView/STTextView+Ruler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ extension STTextView {
open override func rulerView(_ ruler: NSRulerView, handleMouseDownWith event: NSEvent) {
let point = convert(event.locationInWindow, from: nil)
guard let textLayoutFragment = textLayoutManager.textLayoutFragment(for: point),
let textSegmentFrame = textLayoutManager.textSelectionSegmentFrame(at: textLayoutFragment.rangeInElement.location, type: .highlight)?.pixelAligned
let textSegmentFrame = textLayoutManager.textSegmentFrame(at: textLayoutFragment.rangeInElement.location, type: .highlight)?.pixelAligned
else {
return
}
Expand Down
9 changes: 6 additions & 3 deletions Sources/STTextView/STTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,7 @@ open class STTextView: NSView, NSTextInput, NSTextContent {
/// location is too close to each other, therefore `mouseDraggingSelectionAnchors`
/// keep the anchors unchanged while dragging.
internal var mouseDraggingSelectionAnchors: [NSTextSelection]? = nil
internal var draggingSession: NSDraggingSession? = nil

open override class var defaultMenu: NSMenu? {
let menu = super.defaultMenu ?? NSMenu()
Expand Down Expand Up @@ -497,6 +498,8 @@ open class STTextView: NSView, NSTextInput, NSTextContent {
addSubview(selectionView)
addSubview(contentView)

addGestureRecognizer(dragSelectedTextGestureRecognizer())

// Forward didChangeSelectionNotification from STTextLayoutManager
NotificationCenter.default.addObserver(forName: STTextView.didChangeSelectionNotification, object: textLayoutManager, queue: .main) { [weak self] notification in
guard let self = self else { return }
Expand Down Expand Up @@ -876,18 +879,18 @@ open class STTextView: NSView, NSTextInput, NSTextContent {
}

if selectionTextRange.isEmpty {
if let selectionRect = textLayoutManager.textSelectionSegmentFrame(at: selectionTextRange.location, type: .selection) {
if let selectionRect = textLayoutManager.textSegmentFrame(at: selectionTextRange.location, type: .selection) {
scrollToVisible(selectionRect.margin(.init(width: visibleRect.width * 0.1, height: 0)))
}
} else {
switch selection.affinity {
case .upstream:
if let selectionRect = textLayoutManager.textSelectionSegmentFrame(at: selectionTextRange.location, type: .selection) {
if let selectionRect = textLayoutManager.textSegmentFrame(at: selectionTextRange.location, type: .selection) {
scrollToVisible(selectionRect.margin(.init(width: visibleRect.width * 0.1, height: 0)))
}
case .downstream:
if let location = textLayoutManager.location(selectionTextRange.endLocation, offsetBy: -1),
let selectionRect = textLayoutManager.textSelectionSegmentFrame(at: location, type: .selection)
let selectionRect = textLayoutManager.textSegmentFrame(at: location, type: .selection)
{
scrollToVisible(selectionRect.margin(.init(width: visibleRect.width * 0.1, height: 0)))
}
Expand Down
30 changes: 20 additions & 10 deletions Sources/STTextView/TextLayoutRangeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,28 @@ final class TextLayoutRangeView: NSView {
#endif
}

override var intrinsicContentSize: NSSize {
frame.size
}

init(textLayoutManager: NSTextLayoutManager, textRange: NSTextRange) {
self.textLayoutManager = textLayoutManager
self.textRange = textRange

super.init(frame: textLayoutManager.textSelectionSegmentFrame(in: textRange, type: .selection)!)
// Calculate frame. Expand to the size of layout fragments in the asked range
var frame: CGRect = textLayoutManager.textSegmentFrame(in: textRange, type: .standard)!
textLayoutManager.enumerateTextLayoutFragments(in: textRange) { textLayoutFragment in
frame = CGRect(
x: min(frame.origin.x, textLayoutFragment.layoutFragmentFrame.origin.x),
y: frame.origin.y,
width: max(frame.size.width, textLayoutFragment.renderingSurfaceBounds.width),
height: max(frame.size.height, textLayoutFragment.renderingSurfaceBounds.height)
)
return true
}

super.init(frame: frame)
wantsLayer = true
needsDisplay = true
}

required init?(coder: NSCoder) {
Expand All @@ -36,23 +50,18 @@ final class TextLayoutRangeView: NSView {
guard let ctx = NSGraphicsContext.current?.cgContext else { return }

var origin: CGPoint = .zero
textLayoutManager.enumerateTextLayoutFragments(from: textRange.location) { textLayoutFragment in
let shouldContinue = textLayoutFragment.rangeInElement.location <= textRange.endLocation
if !shouldContinue {
return false
}

textLayoutManager.enumerateTextLayoutFragments(in: textRange) { textLayoutFragment in
// at what location start draw the line. the first character is at textRange.location
// I want to draw just a part of the line fragment, however I can only draw the whole line
// so remove/delete unecessary part of the line
for textLineFragment in textLayoutFragment.textLineFragments {
// if textLineFragment contains textRange.location, cut off everything before it
guard let textLineFragmentRange = textLineFragment.textRange(in: textLayoutFragment) else {
continue
}

textLineFragment.draw(at: origin, in: ctx)

// if textLineFragment contains textRange.location, cut off everything before it
if textLineFragmentRange.contains(textRange.location) {
let originOffset = textLineFragment.locationForCharacter(at: textLayoutManager.offset(from: textLineFragmentRange.location, to: textRange.location))
ctx.clear(CGRect(x: origin.x, y: origin.y, width: originOffset.x, height: textLineFragment.typographicBounds.height))
Expand All @@ -61,14 +70,15 @@ final class TextLayoutRangeView: NSView {
if textLineFragmentRange.contains(textRange.endLocation) {
let originOffset = textLineFragment.locationForCharacter(at: textLayoutManager.offset(from: textLineFragmentRange.location, to: textRange.endLocation))
ctx.clear(CGRect(x: originOffset.x, y: origin.y, width: textLineFragment.typographicBounds.width - originOffset.x, height: textLineFragment.typographicBounds.height))
break
}

// TODO: Position does not take RTL, Vertical into account
// let writingDirection = textLayoutManager.baseWritingDirection(at: textRange.location)
origin.y += textLineFragment.typographicBounds.minY + textLineFragment.glyphOrigin.y
}

return shouldContinue // Returning false from block breaks out of the enumeration.
return true
}
}
}
Expand Down
Loading

0 comments on commit c10463d

Please sign in to comment.