Skip to content
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

Fix Marked Text Input #40

Merged
merged 4 commits into from
Jun 22, 2024
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
5 changes: 4 additions & 1 deletion .swiftlint.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
excluded:
- .build

disabled_rules:
- todo
- trailing_comma
Expand All @@ -13,4 +16,4 @@ identifier_name:
excluded:
- c
- id
- vc
- vc
46 changes: 29 additions & 17 deletions Sources/CodeEditTextView/MarkedTextManager/MarkedTextManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@

import AppKit

/// Manages marked ranges
/// Manages marked ranges. Not a public API.
class MarkedTextManager {
/// Struct for passing attribute and range information easily down into line fragments, typesetters w/o
/// requiring a reference to the marked text manager.
struct MarkedRanges {
let ranges: [NSRange]
let attributes: [NSAttributedString.Key: Any]
Expand All @@ -18,7 +20,9 @@ class MarkedTextManager {
private(set) var markedRanges: [NSRange] = []

/// The attributes to use for marked text. Defaults to a single underline when `nil`
var markedTextAttributes: [NSAttributedString.Key: Any]?
var markedTextAttributes: [NSAttributedString.Key: Any] = [
.underlineStyle: NSUnderlineStyle.single.rawValue
]

/// True if there is marked text being tracked.
var hasMarkedText: Bool {
Expand All @@ -31,32 +35,40 @@ class MarkedTextManager {
}

/// Updates the stored marked ranges.
///
/// Two cases here:
/// - No marked ranges yet:
/// - Create new marked ranges from the text selection, with the length of the text being inserted
/// - Marked ranges exist:
/// - Update the existing marked ranges, using the original ranges as a reference. The marked ranges don't
/// change position, so we update each one with the new length and then move it to reflect each cursor's
/// added text.
///
/// - Parameters:
/// - insertLength: The length of the string being inserted.
/// - replacementRange: The range to replace with marked text.
/// - selectedRange: The selected range from `NSTextInput`.
/// - textSelections: The current text selections.
func updateMarkedRanges(
insertLength: Int,
replacementRange: NSRange,
selectedRange: NSRange,
textSelections: [TextSelectionManager.TextSelection]
) {
if replacementRange.location == NSNotFound {
markedRanges = textSelections.map {
NSRange(location: $0.range.location, length: insertLength)
}
func updateMarkedRanges(insertLength: Int, textSelections: [NSRange]) {
var cumulativeExistingDiff = 0
let lengthDiff = insertLength
var newRanges = [NSRange]()
let ranges: [NSRange] = if markedRanges.isEmpty {
textSelections.sorted(by: { $0.location < $1.location })
} else {
markedRanges = [selectedRange]
markedRanges.sorted(by: { $0.location < $1.location })
}

for (idx, range) in ranges.enumerated() {
newRanges.append(NSRange(location: range.location + cumulativeExistingDiff, length: insertLength))
cumulativeExistingDiff += insertLength - range.length
}
markedRanges = newRanges
}

/// Finds any marked ranges for a line and returns them.
/// - Parameter lineRange: The range of the line.
/// - Returns: A `MarkedRange` struct with information about attributes and ranges. `nil` if there is no marked
/// text for this line.
func markedRanges(in lineRange: NSRange) -> MarkedRanges? {
let attributes = markedTextAttributes ?? [.underlineStyle: NSUnderlineStyle.single.rawValue]
let ranges = markedRanges.compactMap {
$0.intersection(lineRange)
}.map {
Expand All @@ -65,7 +77,7 @@ class MarkedTextManager {
if ranges.isEmpty {
return nil
} else {
return MarkedRanges(ranges: ranges, attributes: attributes)
return MarkedRanges(ranges: ranges, attributes: markedTextAttributes)
}
}

Expand Down
30 changes: 20 additions & 10 deletions Sources/CodeEditTextView/TextView/TextView+NSTextInput.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,10 @@ extension TextView: NSTextInputClient {

// MARK: - Marked Text

/// Replaces a specified range in the receiver’s text storage with the given string and sets the selection.
/// Sets up marked text for a marking session. See ``MarkedTextManager`` for more details.
///
/// If there is no marked text, the current selection is replaced. If there is no selection, the string is
/// inserted at the insertion point.
///
/// When `string` is an `NSString` object, the receiver is expected to render the marked text with
/// distinguishing appearance (for example, `NSTextView` renders with `markedTextAttributes`).
/// Decides whether or not to insert/replace text. Then updates the current marked ranges and updates cursor
/// positions.
///
/// - Parameters:
/// - string: The string to insert. Can be either an NSString or NSAttributedString instance.
Expand All @@ -96,13 +93,26 @@ extension TextView: NSTextInputClient {
guard isEditable, let insertString = anyToString(string) else { return }
// Needs to insert text, but not notify the undo manager.
_undoManager?.disable()
let shouldInsert = layoutManager.markedTextManager.markedRanges.isEmpty

// Copy the text selections *before* we modify them.
let selectionCopies = selectionManager.textSelections.map(\.range)

if shouldInsert {
_insertText(insertString: insertString, replacementRange: replacementRange)
} else {
replaceCharacters(in: layoutManager.markedTextManager.markedRanges, with: insertString)
}
layoutManager.markedTextManager.updateMarkedRanges(
insertLength: (insertString as NSString).length,
replacementRange: replacementRange,
selectedRange: selectedRange,
textSelections: selectionManager.textSelections
textSelections: selectionCopies
)
_insertText(insertString: insertString, replacementRange: replacementRange)

// Reset the selected ranges to reflect the replaced text.
selectionManager.setSelectedRanges(layoutManager.markedTextManager.markedRanges.map({
NSRange(location: $0.max, length: 0)
}))

_undoManager?.enable()
}

Expand Down
12 changes: 12 additions & 0 deletions Sources/CodeEditTextView/TextView/TextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,18 @@ public class TextView: NSView, NSTextContent {
}
}

/// The attributes used to render marked text.
/// Defaults to a single underline.
public var markedTextAttributes: [NSAttributedString.Key: Any] {
get {
layoutManager.markedTextManager.markedTextAttributes
}
set {
layoutManager.markedTextManager.markedTextAttributes = newValue
layoutManager.layoutLines() // Layout lines to refresh attributes. This should be rare.
}
}

open var contentType: NSTextContentType?

/// The text view's delegate.
Expand Down
45 changes: 18 additions & 27 deletions Tests/CodeEditTextViewTests/LineEndingTests.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import XCTest
@testable import CodeEditTextView

// swiftlint:disable all

class LineEndingTests: XCTestCase {
func test_lineEndingCreateUnix() {
// The \n character
Expand All @@ -29,64 +27,57 @@ class LineEndingTests: XCTestCase {
}

func test_detectLineEndingDefault() {
// There was a bug in this that caused it to flake sometimes, so we run this a couple times to ensure it's not flaky.
// There was a bug in this that caused it to flake sometimes, so we run this a couple times to ensure it's not
// flaky.
// The odds of it being bad with the earlier bug after running 20 times is incredibly small
for _ in 0..<20 {
let storage = NSTextStorage(string: "hello world") // No line ending
let lineStorage = TextLineStorage<TextLine>()
lineStorage.buildFromTextStorage(storage, estimatedLineHeight: 10)
let detected = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: storage)
XCTAssertTrue(detected == .lineFeed, "Default detected line ending incorrect, expected: \n, got: \(detected.rawValue.debugDescription)")
XCTAssertEqual(detected, .lineFeed)
}
}

let corpus = "abcdefghijklmnopqrstuvwxyz123456789"
func makeRandomText(_ goalLineEnding: LineEnding) -> String {
(10..<Int.random(in: 20..<100)).reduce("") { partialResult, _ in
return partialResult + String(
(0..<Int.random(in: 1..<20)).map { _ in corpus.randomElement()! }
) + goalLineEnding.rawValue
}
}

func test_detectLineEndingUnix() {
let corpus = "abcdefghijklmnopqrstuvwxyz123456789"
let goalLineEnding = LineEnding.lineFeed

let text = (10..<Int.random(in: 20..<100)).reduce("") { partialResult, _ in
return partialResult + String((0..<Int.random(in: 1..<20)).map{ _ in corpus.randomElement()! }) + goalLineEnding.rawValue
}

let storage = NSTextStorage(string: text)
let storage = NSTextStorage(string: makeRandomText(goalLineEnding))
let lineStorage = TextLineStorage<TextLine>()
lineStorage.buildFromTextStorage(storage, estimatedLineHeight: 10)

let detected = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: storage)
XCTAssertTrue(detected == goalLineEnding, "Incorrect detected line ending, expected: \(goalLineEnding.rawValue.debugDescription), got \(detected.rawValue.debugDescription)")
XCTAssertEqual(detected, goalLineEnding)
}

func test_detectLineEndingCLRF() {
let corpus = "abcdefghijklmnopqrstuvwxyz123456789"
let goalLineEnding = LineEnding.carriageReturnLineFeed

let text = (10..<Int.random(in: 20..<100)).reduce("") { partialResult, _ in
return partialResult + String((0..<Int.random(in: 1..<20)).map{ _ in corpus.randomElement()! }) + goalLineEnding.rawValue
}

let storage = NSTextStorage(string: text)
let storage = NSTextStorage(string: makeRandomText(goalLineEnding))
let lineStorage = TextLineStorage<TextLine>()
lineStorage.buildFromTextStorage(storage, estimatedLineHeight: 10)

let detected = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: storage)
XCTAssertTrue(detected == goalLineEnding, "Incorrect detected line ending, expected: \(goalLineEnding.rawValue.debugDescription), got \(detected.rawValue.debugDescription)")
XCTAssertEqual(detected, goalLineEnding)
}

func test_detectLineEndingMacOS() {
let corpus = "abcdefghijklmnopqrstuvwxyz123456789"
let goalLineEnding = LineEnding.carriageReturn

let text = (10..<Int.random(in: 20..<100)).reduce("") { partialResult, _ in
return partialResult + String((0..<Int.random(in: 1..<20)).map{ _ in corpus.randomElement()! }) + goalLineEnding.rawValue
}

let storage = NSTextStorage(string: text)
let storage = NSTextStorage(string: makeRandomText(goalLineEnding))
let lineStorage = TextLineStorage<TextLine>()
lineStorage.buildFromTextStorage(storage, estimatedLineHeight: 10)

let detected = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: storage)
XCTAssertTrue(detected == goalLineEnding, "Incorrect detected line ending, expected: \(goalLineEnding.rawValue.debugDescription), got \(detected.rawValue.debugDescription)")
XCTAssertEqual(detected, goalLineEnding)
}
}

// swiftlint:enable all
127 changes: 127 additions & 0 deletions Tests/CodeEditTextViewTests/MarkedTextTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import XCTest
@testable import CodeEditTextView

class MarkedTextTests: XCTestCase {
func test_markedTextSingleChar() {
let textView = TextView(string: "")
textView.selectionManager.setSelectedRange(.zero)

textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound)
XCTAssertEqual(textView.string, "´")

textView.insertText("é", replacementRange: .notFound)
XCTAssertEqual(textView.string, "é")
XCTAssertEqual(textView.selectionManager.textSelections.map(\.range), [NSRange(location: 1, length: 0)])
}

func test_markedTextSingleCharInStrings() {
let textView = TextView(string: "Lorem Ipsum")
textView.selectionManager.setSelectedRange(NSRange(location: 5, length: 0))

textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound)
XCTAssertEqual(textView.string, "Lorem´ Ipsum")

textView.insertText("é", replacementRange: .notFound)
XCTAssertEqual(textView.string, "Loremé Ipsum")
XCTAssertEqual(textView.selectionManager.textSelections.map(\.range), [NSRange(location: 6, length: 0)])
}

func test_markedTextReplaceSelection() {
let textView = TextView(string: "ABCDE")
textView.selectionManager.setSelectedRange(NSRange(location: 4, length: 1))

textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound)
XCTAssertEqual(textView.string, "ABCD´")

textView.insertText("é", replacementRange: .notFound)
XCTAssertEqual(textView.string, "ABCDé")
XCTAssertEqual(textView.selectionManager.textSelections.map(\.range), [NSRange(location: 5, length: 0)])
}

func test_markedTextMultipleSelection() {
let textView = TextView(string: "ABC")
textView.selectionManager.setSelectedRanges([NSRange(location: 1, length: 0), NSRange(location: 2, length: 0)])

textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound)
XCTAssertEqual(textView.string, "A´B´C")

textView.insertText("é", replacementRange: .notFound)
XCTAssertEqual(textView.string, "AéBéC")
XCTAssertEqual(
textView.selectionManager.textSelections.map(\.range).sorted(by: { $0.location < $1.location }),
[NSRange(location: 2, length: 0), NSRange(location: 4, length: 0)]
)
}

func test_markedTextMultipleSelectionReplaceSelection() {
let textView = TextView(string: "ABCDE")
textView.selectionManager.setSelectedRanges([NSRange(location: 0, length: 1), NSRange(location: 4, length: 1)])

textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound)
XCTAssertEqual(textView.string, "´BCD´")

textView.insertText("é", replacementRange: .notFound)
XCTAssertEqual(textView.string, "éBCDé")
XCTAssertEqual(
textView.selectionManager.textSelections.map(\.range).sorted(by: { $0.location < $1.location }),
[NSRange(location: 1, length: 0), NSRange(location: 5, length: 0)]
)
}

func test_markedTextMultipleSelectionMultipleChar() {
let textView = TextView(string: "ABCDE")
textView.selectionManager.setSelectedRanges([NSRange(location: 0, length: 1), NSRange(location: 4, length: 1)])

textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound)
XCTAssertEqual(textView.string, "´BCD´")

textView.setMarkedText("´´´", selectedRange: .notFound, replacementRange: .notFound)
XCTAssertEqual(textView.string, "´´´BCD´´´")
XCTAssertEqual(
textView.selectionManager.textSelections.map(\.range).sorted(by: { $0.location < $1.location }),
[NSRange(location: 3, length: 0), NSRange(location: 9, length: 0)]
)

textView.insertText("é", replacementRange: .notFound)
XCTAssertEqual(textView.string, "éBCDé")
XCTAssertEqual(
textView.selectionManager.textSelections.map(\.range).sorted(by: { $0.location < $1.location }),
[NSRange(location: 1, length: 0), NSRange(location: 5, length: 0)]
)
}

func test_cancelMarkedText() {
let textView = TextView(string: "")
textView.selectionManager.setSelectedRange(.zero)

textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound)
XCTAssertEqual(textView.string, "´")

// The NSTextInputContext performs the following actions when a marked text segment is ended w/o replacing the
// marked text:
textView.insertText("´", replacementRange: .notFound)
textView.insertText("4", replacementRange: .notFound)

XCTAssertEqual(textView.string, "´4")
XCTAssertEqual(textView.selectionManager.textSelections.map(\.range), [NSRange(location: 2, length: 0)])
}

func test_cancelMarkedTextMultipleCursor() {
let textView = TextView(string: "ABC")
textView.selectionManager.setSelectedRanges([NSRange(location: 1, length: 0), NSRange(location: 2, length: 0)])

textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound)
XCTAssertEqual(textView.string, "A´B´C")

// The NSTextInputContext performs the following actions when a marked text segment is ended w/o replacing the
// marked text:
textView.insertText("´", replacementRange: .notFound)
textView.insertText("4", replacementRange: .notFound)

XCTAssertEqual(textView.string, "A´4B´4C")
XCTAssertEqual(
textView.selectionManager.textSelections.map(\.range).sorted(by: { $0.location < $1.location }),
[NSRange(location: 3, length: 0), NSRange(location: 6, length: 0)]
)
}
}
Loading
Loading