From 2508635e9aa554e246d2185ea2d065d4a54af597 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 18 Jun 2025 10:38:14 -0500 Subject: [PATCH 1/7] Merge Trailing Line on Attachments, Select Attachments (#98) ### Description Fixes a bug with text attachments that ended on the newline character in a line. Eg: ``` A B ``` Text attachment added for range (0..<2) should result in: ``` [Attachment]B ``` Right now it results in the following visible lines. ``` [Attachment]B ``` Also introduces the ability for text attachments to respond to being selected and draw their contents differently. ### Related Issues * https://github.com/CodeEditApp/CodeEditSourceEditor/issues/43 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots With new behavior: https://github.com/user-attachments/assets/3cf665ab-1a9a-4de0-a835-9248d955ab5a --- .../TextAttachments/TextAttachment.swift | 5 +- .../TextAttachmentManager.swift | 61 ++++++++++++++++++- .../TextLayoutManager+Iterator.swift | 10 ++- .../TextLayoutManager+Layout.swift | 2 +- .../TextLayoutManager+Public.swift | 2 +- .../TextLayoutManager/TextLayoutManager.swift | 2 +- .../TextSelectionManager+FillRects.swift | 9 ++- .../CodeEditTextView/TextView/TextView.swift | 2 + .../TextLayoutManagerAttachmentsTests.swift | 10 +++ .../TypesetterTests.swift | 1 + 10 files changed, 93 insertions(+), 11 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift index 61ca777f2..f3bc01209 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift @@ -10,6 +10,7 @@ import AppKit /// Represents an attachment type. Attachments take up some set width, and draw their contents in a receiver view. public protocol TextAttachment: AnyObject { var width: CGFloat { get } + var isSelected: Bool { get set } func draw(in context: CGContext, rect: NSRect) } @@ -18,8 +19,8 @@ public protocol TextAttachment: AnyObject { /// This type cannot be initialized outside of `CodeEditTextView`, but will be received when interrogating /// the ``TextAttachmentManager``. public struct AnyTextAttachment: Equatable { - var range: NSRange - let attachment: any TextAttachment + package(set) public var range: NSRange + public let attachment: any TextAttachment var width: CGFloat { attachment.width diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift index 5bad2de1e..dfa561d84 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift @@ -15,6 +15,7 @@ import Foundation public final class TextAttachmentManager { private var orderedAttachments: [AnyTextAttachment] = [] weak var layoutManager: TextLayoutManager? + private var selectionObserver: (any NSObjectProtocol)? /// Adds a new attachment, keeping `orderedAttachments` sorted by range.location. /// If two attachments overlap, the layout phase will later ignore the one with the higher start. @@ -23,11 +24,28 @@ public final class TextAttachmentManager { let attachment = AnyTextAttachment(range: range, attachment: attachment) let insertIndex = findInsertionIndex(for: range.location) orderedAttachments.insert(attachment, at: insertIndex) + + // This is ugly, but if our attachment meets the end of the next line, we need to merge that line with this + // one. + var getNextOne = false layoutManager?.lineStorage.linesInRange(range).dropFirst().forEach { if $0.height != 0 { layoutManager?.lineStorage.update(atOffset: $0.range.location, delta: 0, deltaHeight: -$0.height) } + + // Only do this if it's not the end of the document + if range.max == $0.range.max && range.max != layoutManager?.lineStorage.length { + getNextOne = true + } + } + + if getNextOne, + let trailingLine = layoutManager?.lineStorage.getLine(atOffset: range.max), + trailingLine.height != 0 { + // Update the one trailing line. + layoutManager?.lineStorage.update(atOffset: range.max, delta: 0, deltaHeight: -trailingLine.height) } + layoutManager?.setNeedsLayout() } @@ -77,7 +95,7 @@ public final class TextAttachmentManager { /// - Returns: An array of `AnyTextAttachment` instances whose ranges intersect `query`. public func getAttachmentsOverlapping(_ range: NSRange) -> [AnyTextAttachment] { // Find the first attachment whose end is beyond the start of the query. - guard let startIdx = firstIndex(where: { $0.range.upperBound > range.location }) else { + guard let startIdx = firstIndex(where: { $0.range.upperBound >= range.location }) else { return [] } @@ -90,8 +108,8 @@ public final class TextAttachmentManager { if attachment.range.location >= range.upperBound { break } - if attachment.range.intersection(range)?.length ?? 0 > 0, - results.last?.range != attachment.range { + if (attachment.range.intersection(range)?.length ?? 0 > 0 || attachment.range.max == range.location) + && results.last?.range != attachment.range { results.append(attachment) } idx += 1 @@ -114,6 +132,43 @@ public final class TextAttachmentManager { } } } + + /// Set up the attachment manager to listen to selection updates, giving text attachments a chance to respond to + /// selection state. + /// + /// This is specifically not in the initializer to prevent a bit of a chicken-and-the-egg situation where the + /// layout manager and selection manager need each other to init. + /// + /// - Parameter selectionManager: The selection manager to listen to. + func setUpSelectionListener(for selectionManager: TextSelectionManager) { + if let selectionObserver { + NotificationCenter.default.removeObserver(selectionObserver) + } + + selectionObserver = NotificationCenter.default.addObserver( + forName: TextSelectionManager.selectionChangedNotification, + object: selectionManager, + queue: .main + ) { [weak self] notification in + guard let selectionManager = notification.object as? TextSelectionManager else { + return + } + let selectedSet = IndexSet(ranges: selectionManager.textSelections.map({ $0.range })) + for attachment in self?.orderedAttachments ?? [] { + let isSelected = selectedSet.contains(integersIn: attachment.range) + if attachment.attachment.isSelected != isSelected { + self?.layoutManager?.invalidateLayoutForRange(attachment.range) + } + attachment.attachment.isSelected = isSelected + } + } + } + + deinit { + if let selectionObserver { + NotificationCenter.default.removeObserver(selectionObserver) + } + } } private extension TextAttachmentManager { diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift index 4e5efede5..ba0a997ed 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift @@ -196,7 +196,7 @@ public extension TextLayoutManager { } if lastAttachment.range.max > originalPosition.position.range.max, - let extendedLinePosition = lineStorage.getLine(atOffset: lastAttachment.range.max) { + var extendedLinePosition = lineStorage.getLine(atOffset: lastAttachment.range.max) { newPosition = TextLineStorage.TextLinePosition( data: newPosition.data, range: NSRange(start: newPosition.range.location, end: extendedLinePosition.range.max), @@ -207,6 +207,14 @@ public extension TextLayoutManager { maxIndex = max(maxIndex, extendedLinePosition.index) } + if firstAttachment.range.location == newPosition.range.location { + minIndex = max(minIndex, 0) + } + + if lastAttachment.range.max == newPosition.range.max { + maxIndex = min(maxIndex, lineStorage.count - 1) + } + // Base case, we haven't updated anything if minIndex...maxIndex == originalPosition.indexRange { return (newPosition, minIndex...maxIndex) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index d7732e37c..074d8fcef 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -250,7 +250,7 @@ extension TextLayoutManager { let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.data.id) { renderDelegate?.lineFragmentView(for: lineFragment.data) ?? LineFragmentView() } - view.translatesAutoresizingMaskIntoConstraints = false + view.translatesAutoresizingMaskIntoConstraints = true // Small optimization for lots of subviews view.setLineFragment(lineFragment.data, renderer: lineFragmentRenderer) view.frame.origin = CGPoint(x: edgeInsets.left, y: yPos) layoutView?.addSubview(view, positioned: .below, relativeTo: nil) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index 6a1a4df61..bca881d05 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -203,7 +203,7 @@ extension TextLayoutManager { /// - line: The line to calculate rects for. /// - Returns: Multiple bounding rects. Will return one rect for each line fragment that overlaps the given range. public func rectsFor(range: NSRange) -> [CGRect] { - return lineStorage.linesInRange(range).flatMap { self.rectsFor(range: range, in: $0) } + return linesInRange(range).flatMap { self.rectsFor(range: range, in: $0) } } /// Calculates all text bounding rects that intersect with a given range, with a given line position. diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index 0985f53d7..503c334c7 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -76,7 +76,7 @@ public class TextLayoutManager: NSObject { // MARK: - Internal weak var textStorage: NSTextStorage? - var lineStorage: TextLineStorage = TextLineStorage() + public var lineStorage: TextLineStorage = TextLineStorage() var markedTextManager: MarkedTextManager = MarkedTextManager() let viewReuseQueue: ViewReuseQueue = ViewReuseQueue() let lineFragmentRenderer: LineFragmentRenderer diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift index da5165f32..f3160bf3e 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift @@ -72,10 +72,15 @@ extension TextSelectionManager { } let maxRect: CGRect + let endOfLine = fragmentRange.max <= range.max || range.contains(fragmentRange.max) + let endOfDocument = intersectionRange.max == layoutManager.lineStorage.length + let emptyLine = linePosition.range.isEmpty + // If the selection is at the end of the line, or contains the end of the fragment, and is not the end // of the document, we select the entire line to the right of the selection point. - if (fragmentRange.max <= range.max || range.contains(fragmentRange.max)) - && intersectionRange.max != layoutManager.lineStorage.length { + // true, !true = false, false + // true, !true = false, true + if endOfLine && !(endOfDocument && !emptyLine) { maxRect = CGRect( x: rect.maxX, y: fragmentPosition.yPos + linePosition.yPos, diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 4c9cf7c31..873694591 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -346,6 +346,8 @@ public class TextView: NSView, NSTextContent { selectionManager = setUpSelectionManager() selectionManager.useSystemCursor = useSystemCursor + layoutManager.attachments.setUpSelectionListener(for: selectionManager) + _undoManager = CEUndoManager(textView: self) layoutManager.layoutLines() diff --git a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift index a3510c608..1841cc5ed 100644 --- a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift +++ b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift @@ -108,4 +108,14 @@ struct TextLayoutManagerAttachmentsTests { // Line "5" is from the trailing newline. That shows up as an empty line in the view. #expect(lines.map { $0.index } == [0, 4]) } + + @Test + func addingAttachmentThatMeetsEndOfLineMergesNextLine() throws { + let height = try #require(layoutManager.textLineForOffset(0)).height + layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 0, end: 3)) + + // With bug: the line for offset 3 would be the 2nd line (index 1). They should be merged + #expect(layoutManager.textLineForOffset(0)?.index == 0) + #expect(layoutManager.textLineForOffset(3)?.index == 0) + } } diff --git a/Tests/CodeEditTextViewTests/TypesetterTests.swift b/Tests/CodeEditTextViewTests/TypesetterTests.swift index e065cb69c..d671ea6ff 100644 --- a/Tests/CodeEditTextViewTests/TypesetterTests.swift +++ b/Tests/CodeEditTextViewTests/TypesetterTests.swift @@ -3,6 +3,7 @@ import XCTest final class DemoTextAttachment: TextAttachment { var width: CGFloat + var isSelected: Bool = false init(width: CGFloat = 100) { self.width = width From c127b47ccf4224e261c09a03271f4013ddaf3896 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 18 Jun 2025 10:38:41 -0500 Subject: [PATCH 2/7] NSBeep On Empty Undo, Remove Unnecessary Redirection (#102) ### Description - When the undo or redo stack is empty, and the user tries to undo or redo, plays the beep sound. - Removes the (now) unnecessary `DelegatedUndoManager` type that sent messages to the `CEUndoManager` type. This was a holdover from when we did not have a custom text view. - Made `CEUndoManager` a subclass of `UndoManager` - Added more overrides to more correctly conform to the `UndoManager` class. ### Related Issues * closes #89 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots --- .../TextView/TextView+UndoRedo.swift | 2 +- .../Utils/CEUndoManager.swift | 110 +++++++----------- .../CodeEditTextViewTests/TextViewTests.swift | 14 +++ 3 files changed, 59 insertions(+), 67 deletions(-) diff --git a/Sources/CodeEditTextView/TextView/TextView+UndoRedo.swift b/Sources/CodeEditTextView/TextView/TextView+UndoRedo.swift index a12f1d830..14d803fba 100644 --- a/Sources/CodeEditTextView/TextView/TextView+UndoRedo.swift +++ b/Sources/CodeEditTextView/TextView/TextView+UndoRedo.swift @@ -14,7 +14,7 @@ extension TextView { } override public var undoManager: UndoManager? { - _undoManager?.manager + _undoManager } @objc func undo(_ sender: AnyObject?) { diff --git a/Sources/CodeEditTextView/Utils/CEUndoManager.swift b/Sources/CodeEditTextView/Utils/CEUndoManager.swift index fd800fcf1..bdf859166 100644 --- a/Sources/CodeEditTextView/Utils/CEUndoManager.swift +++ b/Sources/CodeEditTextView/Utils/CEUndoManager.swift @@ -15,45 +15,7 @@ import TextStory /// - Grouping pasted text /// /// If needed, the automatic undo grouping can be overridden using the `beginGrouping()` and `endGrouping()` methods. -public class CEUndoManager { - /// An `UndoManager` subclass that forwards relevant actions to a `CEUndoManager`. - /// Allows for objects like `TextView` to use the `UndoManager` API - /// while CETV manages the undo/redo actions. - public class DelegatedUndoManager: UndoManager { - weak var parent: CEUndoManager? - - public override var isUndoing: Bool { parent?.isUndoing ?? false } - public override var isRedoing: Bool { parent?.isRedoing ?? false } - public override var canUndo: Bool { parent?.canUndo ?? false } - public override var canRedo: Bool { parent?.canRedo ?? false } - - public func registerMutation(_ mutation: TextMutation) { - parent?.registerMutation(mutation) - removeAllActions() - } - - public override func undo() { - parent?.undo() - } - - public override func redo() { - parent?.redo() - } - - public override func beginUndoGrouping() { - parent?.beginUndoGrouping() - } - - public override func endUndoGrouping() { - parent?.endUndoGrouping() - } - - public override func registerUndo(withTarget target: Any, selector: Selector, object anObject: Any?) { - // no-op, but just in case to save resources: - removeAllActions() - } - } - +public class CEUndoManager: UndoManager { /// Represents a group of mutations that should be treated as one mutation when undoing/redoing. private struct UndoGroup { var mutations: [Mutation] @@ -65,16 +27,17 @@ public class CEUndoManager { var inverse: TextMutation } - public let manager: DelegatedUndoManager - private(set) public var isUndoing: Bool = false - private(set) public var isRedoing: Bool = false + private var _isUndoing: Bool = false + private var _isRedoing: Bool = false - public var canUndo: Bool { - !undoStack.isEmpty - } - public var canRedo: Bool { - !redoStack.isEmpty - } + override public var isUndoing: Bool { _isUndoing } + override public var isRedoing: Bool { _isRedoing } + + override public var undoCount: Int { undoStack.count } + override public var redoCount: Int { redoStack.count } + + override public var canUndo: Bool { !undoStack.isEmpty } + override public var canRedo: Bool { !redoStack.isEmpty } /// A stack of operations that can be undone. private var undoStack: [UndoGroup] = [] @@ -93,10 +56,7 @@ public class CEUndoManager { // MARK: - Init - public init() { - self.manager = DelegatedUndoManager() - manager.parent = self - } + override public init() { } convenience init(textView: TextView) { self.init() @@ -106,37 +66,49 @@ public class CEUndoManager { // MARK: - Undo/Redo /// Performs an undo operation if there is one available. - public func undo() { - guard !isDisabled, let item = undoStack.popLast(), let textView else { + override public func undo() { + guard !isDisabled, let textView else { + return + } + + guard let item = undoStack.popLast() else { + NSSound.beep() return } - isUndoing = true - NotificationCenter.default.post(name: .NSUndoManagerWillUndoChange, object: self.manager) + + _isUndoing = true + NotificationCenter.default.post(name: .NSUndoManagerWillUndoChange, object: self) textView.textStorage.beginEditing() for mutation in item.mutations.reversed() { textView.replaceCharacters(in: mutation.inverse.range, with: mutation.inverse.string) } textView.textStorage.endEditing() - NotificationCenter.default.post(name: .NSUndoManagerDidUndoChange, object: self.manager) + NotificationCenter.default.post(name: .NSUndoManagerDidUndoChange, object: self) redoStack.append(item) - isUndoing = false + _isUndoing = false } /// Performs a redo operation if there is one available. - public func redo() { - guard !isDisabled, let item = redoStack.popLast(), let textView else { + override public func redo() { + guard !isDisabled, let textView else { + return + } + + guard let item = redoStack.popLast() else { + NSSound.beep() return } - isRedoing = true - NotificationCenter.default.post(name: .NSUndoManagerWillRedoChange, object: self.manager) + + _isRedoing = true + NotificationCenter.default.post(name: .NSUndoManagerWillRedoChange, object: self) textView.textStorage.beginEditing() for mutation in item.mutations { textView.replaceCharacters(in: mutation.mutation.range, with: mutation.mutation.string) } textView.textStorage.endEditing() - NotificationCenter.default.post(name: .NSUndoManagerDidRedoChange, object: self.manager) + NotificationCenter.default.post(name: .NSUndoManagerDidRedoChange, object: self) undoStack.append(item) - isRedoing = false + _isRedoing = false } /// Clears the undo/redo stacks. @@ -147,11 +119,17 @@ public class CEUndoManager { // MARK: - Mutations + public override func registerUndo(withTarget target: Any, selector: Selector, object anObject: Any?) { + // no-op, but just in case to save resources: + removeAllActions() + } + /// Registers a mutation into the undo stack. /// /// Calling this method while the manager is in an undo/redo operation will result in a no-op. /// - Parameter mutation: The mutation to register for undo/redo public func registerMutation(_ mutation: TextMutation) { + removeAllActions() guard let textView, let textStorage = textView.textStorage, !isUndoing, @@ -178,7 +156,7 @@ public class CEUndoManager { // MARK: - Grouping /// Groups all incoming mutations. - public func beginUndoGrouping() { + override public func beginUndoGrouping() { guard !isGrouping else { return } isGrouping = true // This is a new undo group, break for it. @@ -186,7 +164,7 @@ public class CEUndoManager { } /// Stops grouping all incoming mutations. - public func endUndoGrouping() { + override public func endUndoGrouping() { guard isGrouping else { return } isGrouping = false // We just ended a group, do not allow the next mutation to be added to the group we just made. diff --git a/Tests/CodeEditTextViewTests/TextViewTests.swift b/Tests/CodeEditTextViewTests/TextViewTests.swift index d8ac192b1..7b6ba44bb 100644 --- a/Tests/CodeEditTextViewTests/TextViewTests.swift +++ b/Tests/CodeEditTextViewTests/TextViewTests.swift @@ -64,4 +64,18 @@ struct TextViewTests { #expect(textView1.layoutManager.lineCount == 3) #expect(textView2.layoutManager.lineCount == 3) } + + @Test("Custom UndoManager class receives events") + func customUndoManagerReceivesEvents() { + let textView = TextView(string: "") + + textView.replaceCharacters(in: .zero, with: "Hello World") + textView.undo(nil) + + #expect(textView.string == "") + + textView.redo(nil) + + #expect(textView.string == "Hello World") + } } From a8a0e9c7bc0eb2278d83491973c9e479d99b0a2a Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 18 Jun 2025 10:50:55 -0500 Subject: [PATCH 3/7] Correctly Group Drag-and-drop for Undo (#104) ### Description Moves the `endUndoGrouping` call to the end of the actual editing operation when dropping text. Makes it so only one undo command is needed to undo a drag-and-drop operation. ### Related Issues * closes #99 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots https://github.com/user-attachments/assets/35952874-6d4d-4702-b149-f06b9ffb5226 --- Sources/CodeEditTextView/TextView/TextView+Drag.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CodeEditTextView/TextView/TextView+Drag.swift b/Sources/CodeEditTextView/TextView/TextView+Drag.swift index 186d25dbd..dbd878a3c 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Drag.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Drag.swift @@ -255,10 +255,10 @@ extension TextView: NSDraggingSource { insertText("") // Replace the selected ranges with nothing } - undoManager?.endUndoGrouping() - replaceCharacters(in: [NSRange(location: insertionOffset, length: 0)], with: insertionString) + undoManager?.endUndoGrouping() + selectionManager.setSelectedRange( NSRange(location: insertionOffset, length: NSString(string: insertionString).length) ) From 7d63a64feab6e3fd4d0ece3e5eda19cb68791a82 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 18 Jun 2025 15:38:04 -0500 Subject: [PATCH 4/7] Select Undone/Redone Text (#105) ### Description Updates the undo manager to update the text selection when undoing/redoing. Two cases: - Replaced/Inserted text - selects the inserted text - Deleted text - Places cursor at the start of the deleted text, as if the user had just deleted it. ### Related Issues * closes #95 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots https://github.com/user-attachments/assets/55309316-9106-4ce2-ac25-952e2addbbfe --- .../TextView/TextView+ScrollToVisible.swift | 7 ++--- .../Utils/CEUndoManager.swift | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift b/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift index 00475ef9f..c4cf1fa6c 100644 --- a/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift +++ b/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift @@ -14,19 +14,17 @@ extension TextView { /// Scrolls the upmost selection to the visible rect if `scrollView` is not `nil`. public func scrollSelectionToVisible() { - guard let scrollView, let selection = getSelection() else { + guard let scrollView else { return } - let offsetToScrollTo = offsetNotPivot(selection) - // There's a bit of a chicken-and-the-egg issue going on here. We need to know the rect to scroll to, but we // can't know the exact rect to make visible without laying out the text. Then, once text is laid out the // selection rect may be different again. To solve this, we loop until the frame doesn't change after a layout // pass and scroll to that rect. var lastFrame: CGRect = .zero - while let boundingRect = layoutManager.rectForOffset(offsetToScrollTo), lastFrame != boundingRect { + while let boundingRect = getSelection()?.boundingRect, lastFrame != boundingRect { lastFrame = boundingRect layoutManager.layoutLines() selectionManager.updateSelectionViews() @@ -34,6 +32,7 @@ extension TextView { } if lastFrame != .zero { scrollView.contentView.scrollToVisible(lastFrame) + scrollView.reflectScrolledClipView(scrollView.contentView) } } diff --git a/Sources/CodeEditTextView/Utils/CEUndoManager.swift b/Sources/CodeEditTextView/Utils/CEUndoManager.swift index bdf859166..722b188ed 100644 --- a/Sources/CodeEditTextView/Utils/CEUndoManager.swift +++ b/Sources/CodeEditTextView/Utils/CEUndoManager.swift @@ -83,6 +83,10 @@ public class CEUndoManager: UndoManager { textView.replaceCharacters(in: mutation.inverse.range, with: mutation.inverse.string) } textView.textStorage.endEditing() + + updateSelectionsForMutations(mutations: item.mutations.map { $0.mutation }) + textView.scrollSelectionToVisible() + NotificationCenter.default.post(name: .NSUndoManagerDidUndoChange, object: self) redoStack.append(item) _isUndoing = false @@ -101,16 +105,41 @@ public class CEUndoManager: UndoManager { _isRedoing = true NotificationCenter.default.post(name: .NSUndoManagerWillRedoChange, object: self) + textView.selectionManager.removeCursors() textView.textStorage.beginEditing() for mutation in item.mutations { textView.replaceCharacters(in: mutation.mutation.range, with: mutation.mutation.string) } textView.textStorage.endEditing() + + updateSelectionsForMutations(mutations: item.mutations.map { $0.inverse }) + textView.scrollSelectionToVisible() + NotificationCenter.default.post(name: .NSUndoManagerDidRedoChange, object: self) undoStack.append(item) _isRedoing = false } + /// We often undo/redo a group of mutations that contain updated ranges that are next to each other but for a user + /// should be one continuous range. This merges those ranges into a set of disjoint ranges before updating the + /// selection manager. + private func updateSelectionsForMutations(mutations: [TextMutation]) { + if mutations.reduce(0, { $0 + $1.range.length }) == 0 { + if let minimumMutation = mutations.min(by: { $0.range.location < $1.range.location }) { + // If the mutations are only deleting text (no replacement), we just place the cursor at the last range, + // since all the ranges are the same but the other method will return no ranges (empty range). + textView?.selectionManager.setSelectedRange( + NSRange(location: minimumMutation.range.location, length: 0) + ) + } + } else { + let mergedRanges = mutations.reduce(into: IndexSet(), { set, mutation in + set.insert(range: mutation.range) + }) + textView?.selectionManager.setSelectedRanges(mergedRanges.rangeView.map { NSRange($0) }) + } + } + /// Clears the undo/redo stacks. public func clearStack() { undoStack.removeAll() From 48cef38070f2f6573a5990346fc4490afb88bc5e Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 19 Jun 2025 13:54:52 -0500 Subject: [PATCH 5/7] Fix Cursor Lagging - Update Cursors in `TextView.layout` (#109) ### Description Addresses an issue described by @nkleemann in the linked issue. The issue was a bug where cursors would lag behind an edit. This is due to the edited line's layout information being invalidated by the edit, meaning the selection manager cannot get valid layout information to position cursors. This change puts a selection position update in the text view's `layout` call, after the layout manager has done it's layout pass. This ensures the selection manage is using valid layout information, fixing the lagging issue. ### Related Issues * https://github.com/CodeEditApp/CodeEditSourceEditor/issues/317 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots On v0.11.2: https://github.com/user-attachments/assets/f4f23e02-58e5-410b-ba1a-0ea5e449dce4 With this change: https://github.com/user-attachments/assets/663fdecb-c1dd-43ec-9990-5f8c7da07205 --- .../TextSelectionManager.swift | 108 ++++++++++-------- .../TextView/TextView+Layout.swift | 1 + 2 files changed, 59 insertions(+), 50 deletions(-) diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift index deff69375..c1c3e0212 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift @@ -136,72 +136,80 @@ public class TextSelectionManager: NSObject { // MARK: - Selection Views - /// Update all selection cursors. Placing them in the correct position for each text selection and reseting the - /// blink timer. - func updateSelectionViews(force: Bool = false) { + /// Update all selection cursors. Placing them in the correct position for each text selection and + /// optionally reseting the blink timer. + func updateSelectionViews(force: Bool = false, skipTimerReset: Bool = false) { guard textView?.isFirstResponder ?? false else { return } var didUpdate: Bool = false for textSelection in textSelections { if textSelection.range.isEmpty { - guard let cursorRect = layoutManager?.rectForOffset(textSelection.range.location) else { - continue - } - - var doesViewNeedReposition: Bool - - // If using the system cursor, macOS will change the origin and height by about 0.5, so we do an - // approximate equals in that case to avoid extra updates. - if useSystemCursor, #available(macOS 14.0, *) { - doesViewNeedReposition = !textSelection.boundingRect.origin.approxEqual(cursorRect.origin) - || !textSelection.boundingRect.height.approxEqual(layoutManager?.estimateLineHeight() ?? 0) - } else { - doesViewNeedReposition = textSelection.boundingRect.origin != cursorRect.origin - || textSelection.boundingRect.height != layoutManager?.estimateLineHeight() ?? 0 - } - - if textSelection.view == nil || doesViewNeedReposition { - let cursorView: NSView + didUpdate = didUpdate || repositionCursorSelection(textSelection: textSelection) + } else if !textSelection.range.isEmpty && textSelection.view != nil { + textSelection.view?.removeFromSuperview() + textSelection.view = nil + didUpdate = true + } + } - if let existingCursorView = textSelection.view { - cursorView = existingCursorView - } else { - textSelection.view?.removeFromSuperview() - textSelection.view = nil + if didUpdate || force { + delegate?.setNeedsDisplay() + if !skipTimerReset { + cursorTimer.resetTimer() + resetSystemCursorTimers() + } + } + } - if useSystemCursor, #available(macOS 14.0, *) { - let systemCursorView = NSTextInsertionIndicator(frame: .zero) - cursorView = systemCursorView - systemCursorView.displayMode = .automatic - } else { - let internalCursorView = CursorView(color: insertionPointColor) - cursorView = internalCursorView - cursorTimer.register(internalCursorView) - } + private func repositionCursorSelection(textSelection: TextSelection) -> Bool { + guard let cursorRect = layoutManager?.rectForOffset(textSelection.range.location) else { + return false + } - textView?.addSubview(cursorView, positioned: .above, relativeTo: nil) - } + var doesViewNeedReposition: Bool - cursorView.frame.origin = cursorRect.origin - cursorView.frame.size.height = cursorRect.height + // If using the system cursor, macOS will change the origin and height by about 0.5, so we do an + // approximate equals in that case to avoid extra updates. + if useSystemCursor, #available(macOS 14.0, *) { + doesViewNeedReposition = !textSelection.boundingRect.origin.approxEqual(cursorRect.origin) + || !textSelection.boundingRect.height.approxEqual(layoutManager?.estimateLineHeight() ?? 0) + } else { + doesViewNeedReposition = textSelection.boundingRect.origin != cursorRect.origin + || textSelection.boundingRect.height != layoutManager?.estimateLineHeight() ?? 0 + } - textSelection.view = cursorView - textSelection.boundingRect = cursorView.frame + if textSelection.view == nil || doesViewNeedReposition { + let cursorView: NSView - didUpdate = true - } - } else if !textSelection.range.isEmpty && textSelection.view != nil { + if let existingCursorView = textSelection.view { + cursorView = existingCursorView + } else { textSelection.view?.removeFromSuperview() textSelection.view = nil - didUpdate = true + + if useSystemCursor, #available(macOS 14.0, *) { + let systemCursorView = NSTextInsertionIndicator(frame: .zero) + cursorView = systemCursorView + systemCursorView.displayMode = .automatic + } else { + let internalCursorView = CursorView(color: insertionPointColor) + cursorView = internalCursorView + cursorTimer.register(internalCursorView) + } + + textView?.addSubview(cursorView, positioned: .above, relativeTo: nil) } - } - if didUpdate || force { - delegate?.setNeedsDisplay() - cursorTimer.resetTimer() - resetSystemCursorTimers() + cursorView.frame.origin = cursorRect.origin + cursorView.frame.size.height = cursorRect.height + + textSelection.view = cursorView + textSelection.boundingRect = cursorView.frame + + return true } + + return false } private func resetSystemCursorTimers() { diff --git a/Sources/CodeEditTextView/TextView/TextView+Layout.swift b/Sources/CodeEditTextView/TextView/TextView+Layout.swift index 5a4162252..2fef2aa1b 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Layout.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Layout.swift @@ -11,6 +11,7 @@ extension TextView { override public func layout() { super.layout() layoutManager.layoutLines() + selectionManager.updateSelectionViews(skipTimerReset: true) } open override class var isCompatibleWithResponsiveScrolling: Bool { From 3f96de5714898f2f753271b48d70f98edb81502a Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 23 Jun 2025 08:49:24 -0500 Subject: [PATCH 6/7] Column Selection (#107) ### Description Adds column selection when pressing option and dragging to select. ### Related Issues * #46 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots Demo, ignore the multiple undos at the end a fix for that is on it's way. https://github.com/user-attachments/assets/409aa49c-eed2-465f-a964-162920c8877d --- .../TextLayoutManager+Public.swift | 22 +++-- .../TextSelectionManager.swift | 2 + .../TextView/TextView+ColumnSelection.swift | 50 ++++++++++ .../TextView/TextView+FirstResponder.swift | 5 +- .../TextView/TextView+KeyDown.swift | 12 +++ .../TextView/TextView+Mouse.swift | 92 +++++++++++-------- .../CodeEditTextView/TextView/TextView.swift | 2 + 7 files changed, 141 insertions(+), 44 deletions(-) create mode 100644 Sources/CodeEditTextView/TextView/TextView+ColumnSelection.swift diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index bca881d05..19f67793a 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -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.TextLinePosition, + linePosition: TextLineStorage.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.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: diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift index c1c3e0212..2bc3d1d4c 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift @@ -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() diff --git a/Sources/CodeEditTextView/TextView/TextView+ColumnSelection.swift b/Sources/CodeEditTextView/TextView/TextView+ColumnSelection.swift new file mode 100644 index 000000000..ad2e63102 --- /dev/null +++ b/Sources/CodeEditTextView/TextView/TextView+ColumnSelection.swift @@ -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) + } +} diff --git a/Sources/CodeEditTextView/TextView/TextView+FirstResponder.swift b/Sources/CodeEditTextView/TextView/TextView+FirstResponder.swift index 39588a262..968c1ede3 100644 --- a/Sources/CodeEditTextView/TextView/TextView+FirstResponder.swift +++ b/Sources/CodeEditTextView/TextView/TextView+FirstResponder.swift @@ -51,7 +51,10 @@ extension TextView { open override func resetCursorRects() { super.resetCursorRects() if isSelectable { - addCursorRect(visibleRect, cursor: .iBeam) + addCursorRect( + visibleRect, + cursor: isOptionPressed ? .crosshair : .iBeam + ) } } } diff --git a/Sources/CodeEditTextView/TextView/TextView+KeyDown.swift b/Sources/CodeEditTextView/TextView/TextView+KeyDown.swift index 1ef36d4f5..e11187851 100644 --- a/Sources/CodeEditTextView/TextView/TextView+KeyDown.swift +++ b/Sources/CodeEditTextView/TextView/TextView+KeyDown.swift @@ -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() + } + } } diff --git a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift index db1a96d1b..a6e447999 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift @@ -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) + } } diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 873694591..f7537fb99 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -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 } From df485cb63e163c9bdc68ec0617c113d301368da6 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 23 Jun 2025 10:34:22 -0500 Subject: [PATCH 7/7] Track Mouse Drag Outside View (#108) ### Description Fixes a bug that didn't allow the view to track drags outside it's bounds. Users can now drag selections over other views and outside the window. ### Related Issues * closes #100 * closes https://github.com/CodeEditApp/CodeEditSourceEditor/issues/316 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots https://github.com/user-attachments/assets/ffbad5bf-5a56-4ef8-91f7-c5a9bb725208 --- .../TextView/TextView+Mouse.swift | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift index a6e447999..636ea472a 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift @@ -87,19 +87,27 @@ extension TextView { return } + // We receive global events because our view received the drag event, but we need to clamp the potentially + // out-of-bounds positions to a position our layout manager can deal with. + let locationInWindow = convert(event.locationInWindow, from: nil) + let locationInView = CGPoint( + x: max(0.0, min(locationInWindow.x, frame.width)), + y: max(0.0, min(locationInWindow.y, frame.height)) + ) + if mouseDragAnchor == nil { - mouseDragAnchor = convert(event.locationInWindow, from: nil) + mouseDragAnchor = locationInView super.mouseDragged(with: event) } else { guard let mouseDragAnchor, let startPosition = layoutManager.textOffsetAtPoint(mouseDragAnchor), - let endPosition = layoutManager.textOffsetAtPoint(convert(event.locationInWindow, from: nil)) else { + let endPosition = layoutManager.textOffsetAtPoint(locationInView) else { return } let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) if modifierFlags.contains(.option) { - dragColumnSelection(mouseDragAnchor: mouseDragAnchor, event: event) + dragColumnSelection(mouseDragAnchor: mouseDragAnchor, locationInView: locationInView) } else { dragSelection(startPosition: startPosition, endPosition: endPosition, mouseDragAnchor: mouseDragAnchor) } @@ -197,9 +205,7 @@ extension TextView { } } - 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) + private func dragColumnSelection(mouseDragAnchor: CGPoint, locationInView: CGPoint) { + selectColumns(betweenPointA: mouseDragAnchor, pointB: locationInView) } }