Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// NSRange+removing.swift
// TableProTextEngine
//

import Foundation

extension NSRange {
/// This range in the document left once `removed` is taken out. `removed` holds disjoint ranges of the document
/// before; any part of this range inside one of them goes with it.
func removing(_ removed: [NSRange]) -> NSRange {
func shifted(_ offset: Int) -> Int {
offset - removed.reduce(0) { $0 + Swift.max(0, Swift.min($1.max, offset) - $1.location) }
}
let start = shifted(location)
return NSRange(location: start, length: shifted(max) - start)
}

/// The characters this range shares with `other`, or `nil` when it shares none.
func overlap(with other: NSRange) -> NSRange? {
let start = Swift.max(location, other.location)
let end = Swift.min(max, other.max)
return end > start ? NSRange(location: start, length: end - start) : nil
}

/// The parts of this range outside `other`, in order.
func subtracting(_ other: NSRange) -> [NSRange] {
let before = NSRange(location: location, length: Swift.max(0, Swift.min(max, other.location) - location))
let afterStart = Swift.max(location, other.max)
let after = NSRange(location: afterStart, length: Swift.max(0, max - afterStart))
return [before, after].filter { $0.length > 0 }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,33 +46,17 @@ class MarkedTextManager {
markedRanges = resolved
}

/// 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.
///
/// Marks the composition's text after it replaced `replacedRanges`, which are in the coordinates of the text
/// before that replacement: each mark moves by what the ones before it added.
/// - Parameters:
/// - insertLength: The length of the string being inserted.
/// - textSelections: The current text selections.
func updateMarkedRanges(insertLength: Int, textSelections: [NSRange]) {
var cumulativeExistingDiff = 0
var newRanges = [NSRange]()
let ranges: [NSRange] = if markedRanges.isEmpty {
textSelections.sorted(by: { $0.location < $1.location })
} else {
markedRanges.sorted(by: { $0.location < $1.location })
}

for range in ranges {
newRanges.append(NSRange(location: range.location + cumulativeExistingDiff, length: insertLength))
cumulativeExistingDiff += insertLength - range.length
/// - insertLength: The length of the composition's text.
/// - replacedRanges: The ranges the composition's text replaced.
func updateMarkedRanges(insertLength: Int, replacedRanges: [NSRange]) {
var shift = 0
markedRanges = replacedRanges.sorted(by: { $0.location < $1.location }).map { range in
defer { shift += insertLength - range.length }
return NSRange(location: range.location + shift, length: insertLength)
}
markedRanges = newRanges
}

/// Finds any marked ranges for a line and returns them.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import AppKit
/// then e again to insert the é character.
///
/// The text view needs to maintain a range of marked text and apply attributes indicating the text is marked. When
/// selection is updated, the marked text range can be discarded if the cursor leaves the marked text range.
/// the selection leaves the marked text range, the composition is accepted as typed text.
///
/// ## Notes for multiple cursors
///
Expand Down Expand Up @@ -73,15 +73,6 @@ extension TextView: NSTextInputClient {
/// - Parameters:
/// - string: The text to insert, either an NSString or NSAttributedString instance.
/// - replacementRange: The range of content to replace in the receiver’s text storage.
///
/// IME commits arrive here with `replacementRange` either set to `NSNotFound` (replace the
/// marked range(s)) or to the explicit range to replace. Calling `unmarkText()` first would
/// wipe the marked text via `replaceCharacters(_:with: "")` and then `_insertText` would
/// replace a now-stale range, either eating unrelated content or hitting an out-of-bounds
/// range that lands the caret at `documentLength`. Resolve the effective range(s) first,
/// then clear marked-text bookkeeping without touching the storage, so the actual replacement
/// is a single edit. Multi-cursor IME duplicates the marked range across every cursor; the
/// `NSNotFound` path replaces all of them in one pass.
@objc public func insertText(_ string: Any, replacementRange: NSRange) {
guard isEditable, let insertString = typedText(from: string) else { return }
commitText(insertString, replacementRange: replacementRange)
Expand All @@ -95,28 +86,46 @@ extension TextView: NSTextInputClient {
return typed
}

/// Commits a composition, or inserts typed text when there is none.
///
/// The composed text being replaced comes out first, unrecorded and unfiltered like every step of the
/// composition, and the committed string goes in where it was as typed text. The typing filters then see what a
/// keyboard without dead keys sends: an accent and Space commit `'` as an insertion of `'`, not as a quote
/// replacing a one-character selection, which the pair filter would wrap.
///
/// An explicit `replacementRange` replaces only the composed text inside it; the rest is accepted as typed. A
/// range that names no position in the document replaces the whole composition, as `NSNotFound` does.
func commitText(_ insertString: String, replacementRange: NSRange) {
layoutManager.markedTextManager.resolveRanges(inDocumentOfLength: textStorage.length)
let markedRanges = layoutManager.markedTextManager.markedRanges
let hadMarkedText = !markedRanges.isEmpty

if hadMarkedText {
layoutManager.markedTextManager.removeAll()
layoutManager.setNeedsLayout()
needsLayout = true
inputContext?.discardMarkedText()
let explicitRange = replacementRange.resolved(inDocumentOfLength: textStorage.length)
guard let composedRanges = endComposition() else {
_insertText(insertString: insertString, replacementRange: replacementRange)
return
}
guard let target = explicitRange ?? (composedRanges.count == 1 ? composedRanges[0] : nil) else {
commitInPlace(insertString, at: composedRanges)
return
}
let replaced = composedRanges.compactMap { $0.overlap(with: target) }
let kept = composedRanges.flatMap { $0.subtracting(target) }
replaceComposedCharacters(in: replaced, with: "")
editorUndoManager?.registerInsertions(in: kept.map { $0.removing(replaced) })
_insertText(insertString: insertString, replacementRanges: [target.removing(replaced)])
}

if replacementRange.location != NSNotFound {
_insertText(insertString: insertString, replacementRange: replacementRange)
} else if hadMarkedText {
_insertText(insertString: insertString, replacementRanges: markedRanges)
} else {
_insertText(
insertString: insertString,
replacementRange: NSRange(location: NSNotFound, length: 0)
)
/// Commits a composition held at several carets by turning each one into the committed string, accepted as typed.
///
/// The typing filters read and set one caret (`TextInterface.selectedRange`), so they cannot serve several. Taking
/// the compositions out first would also merge carets whose compositions touch, and lose a commit with them.
private func commitInPlace(_ insertString: String, at composedRanges: [NSRange]) {
replaceComposedCharacters(in: composedRanges, with: insertString)
let length = (insertString as NSString).length
var shift = 0
let committed = composedRanges.map { range in
defer { shift += length - range.length }
return NSRange(location: range.location + shift, length: length)
}
editorUndoManager?.registerInsertions(in: committed)
selectionManager.textSelections.forEach { $0.suggestedXPos = nil }
}

override public func insertText(_ insertString: Any) {
Expand All @@ -127,58 +136,94 @@ extension TextView: NSTextInputClient {

/// Sets up marked text for a marking session. See ``MarkedTextManager`` for more details.
///
/// Decides whether or not to insert/replace text. Then updates the current marked ranges and updates cursor
/// positions.
/// The composition is provisional: its keystrokes skip the typing filters and the undo history, and only what
/// it ends as is recorded. The text it replaces when it begins is recorded as deleted.
///
/// - Parameters:
/// - string: The string to insert. Can be either an NSString or NSAttributedString instance.
/// - selectedRange: The range to set as the selection, computed from the beginning of the inserted string.
/// - replacementRange: The range to replace, computed from the beginning of the marked text.
@objc public func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
guard isEditable, let insertString = typedText(from: string) else { return }
// Needs to insert text, but not notify the undo manager.
editorUndoManager?.disable()
layoutManager.markedTextManager.resolveRanges(inDocumentOfLength: textStorage.length)
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(
let markedTextManager = layoutManager.markedTextManager
markedTextManager.resolveRanges(inDocumentOfLength: textStorage.length)

let composedRanges = markedTextManager.hasMarkedText
? markedTextManager.markedRanges
: removeTextReplacedByComposition(replacementRange)
replaceComposedCharacters(in: composedRanges, with: insertString)
markedTextManager.updateMarkedRanges(
insertLength: (insertString as NSString).length,
textSelections: selectionCopies
replacedRanges: composedRanges
)

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

if insertString.isEmpty {
layoutManager.markedTextManager.removeAll()
markedTextManager.removeAll()
}
}

/// Takes out what a new composition replaces: the selections, or the range the input method names. Nothing was
/// typed yet, so the filters stay out, but the removal is the user's edit and is recorded.
/// - Returns: Where the composition goes, ascending.
private func removeTextReplacedByComposition(_ replacementRange: NSRange) -> [NSRange] {
let targets = replacementRange.location == NSNotFound
? selectionManager.textSelections.map(\.range)
: [replacementRange]
let ranges = resolvedReplacementRanges(targets).sorted { $0.location < $1.location }
let replaced = ranges.filter { !$0.isEmpty }
if !replaced.isEmpty {
replaceCharacters(
replaced.map { TextReplacement(range: $0, string: "") },
skipUpdateSelection: false,
asksDelegate: false
)
}
return ranges.map { NSRange(location: $0.location, length: 0).removing(replaced) }
}

private func replaceComposedCharacters(in ranges: [NSRange], with string: String) {
editorUndoManager?.disable()
defer { editorUndoManager?.enable() }
replaceCharacters(
ranges.map { TextReplacement(range: $0, string: string) },
skipUpdateSelection: false,
asksDelegate: false
)
}

editorUndoManager?.enable()
/// Ends the composition and returns the ranges it held, ascending, or `nil` when there was none.
private func endComposition() -> [NSRange]? {
let markedTextManager = layoutManager.markedTextManager
markedTextManager.resolveRanges(inDocumentOfLength: textStorage.length)
let composedRanges = markedTextManager.markedRanges.sorted { $0.location < $1.location }
guard !composedRanges.isEmpty else { return nil }
markedTextManager.removeAll()
layoutManager.setNeedsLayout()
needsLayout = true
inputContext?.discardMarkedText()
return composedRanges
}

/// Ends the composition and keeps its text where it is, recorded as typed: what `NSTextInputClient` asks of
/// `unmarkText`, and what `NSTextView` does when a click, an edit or an undo interrupts a composition.
func acceptComposition() {
guard let composedRanges = endComposition() else { return }
editorUndoManager?.registerInsertions(in: composedRanges)
}

/// Unmarks text and causes layout if needed after a selection update.
/// Accepts the composition once a selection change leaves it.
func unmarkTextIfNeeded() {
guard layoutManager.markedTextManager.hasMarkedText,
layoutManager.markedTextManager.updateForNewSelections(
textSelections: selectionManager.textSelections
) else {
return
}

layoutManager.markedTextManager.removeAll()
layoutManager.setNeedsLayout()
needsLayout = true
inputContext?.discardMarkedText()
acceptComposition()
}

/// Unmarks the marked text.
Expand All @@ -187,16 +232,7 @@ extension TextView: NSTextInputClient {
/// The text view should accept the marked text as if it had been inserted normally.
/// If there is no marked text, the invocation of this method has no effect.
@objc public func unmarkText() {
if layoutManager.markedTextManager.hasMarkedText {
editorUndoManager?.disable()
layoutManager.markedTextManager.resolveRanges(inDocumentOfLength: textStorage.length)
replaceCharacters(in: layoutManager.markedTextManager.markedRanges, with: "")
editorUndoManager?.enable()
layoutManager.markedTextManager.removeAll()
layoutManager.setNeedsLayout()
needsLayout = true
inputContext?.discardMarkedText()
}
acceptComposition()
}

/// Returns the range of selected text.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,32 @@ public extension TextView {
/// Replaces each range with its own string as one edit: one text change notification, one undo mutation per
/// range, and the selection carried through every replacement. Ranges are in the coordinates of the text before
/// the edit and must not overlap.
///
/// An input method's composition still open is accepted first, as `NSTextView` does, so the history holds the
/// composed text before this edit. Accepting it moves no text, so the ranges stay valid.
/// - Parameters:
/// - replacements: The ranges to replace and what to put in each.
/// - skipUpdateSelection: Skips the selection update step
func replaceCharacters(_ replacements: [TextReplacement], skipUpdateSelection: Bool = false) {
acceptComposition()
replaceCharacters(replacements, skipUpdateSelection: skipUpdateSelection, asksDelegate: true)
}

/// `asksDelegate` is false for an input method's own edits: the delegate's `shouldReplaceContentsIn` is where the
/// editor runs its typing filters, and a composition is not typing until it is committed.
internal func replaceCharacters(
_ replacements: [TextReplacement],
skipUpdateSelection: Bool,
asksDelegate: Bool
) {
guard isEditable else { return }
NotificationCenter.default.post(name: Self.textWillChangeNotification, object: self)
textStorage.beginEditing()

func valid(range: NSRange, string: String) -> Bool {
(!range.isEmpty || !string.isEmpty) &&
(delegate?.textView(self, shouldReplaceContentsIn: range, with: string) ?? true)
guard !range.isEmpty || !string.isEmpty else { return false }
guard asksDelegate else { return true }
return delegate?.textView(self, shouldReplaceContentsIn: range, with: string) ?? true
}

// Can't insert an empty string into an empty range. One must be not empty
Expand Down
Loading
Loading