diff --git a/apple/Packages/TableProEditor/Sources/TableProTextEngine/Extensions/NSRange+/NSRange+removing.swift b/apple/Packages/TableProEditor/Sources/TableProTextEngine/Extensions/NSRange+/NSRange+removing.swift new file mode 100644 index 0000000000..2c3ea86f5f --- /dev/null +++ b/apple/Packages/TableProEditor/Sources/TableProTextEngine/Extensions/NSRange+/NSRange+removing.swift @@ -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 } + } +} diff --git a/apple/Packages/TableProEditor/Sources/TableProTextEngine/MarkedTextManager/MarkedTextManager.swift b/apple/Packages/TableProEditor/Sources/TableProTextEngine/MarkedTextManager/MarkedTextManager.swift index b36e5835e1..444cbfa226 100644 --- a/apple/Packages/TableProEditor/Sources/TableProTextEngine/MarkedTextManager/MarkedTextManager.swift +++ b/apple/Packages/TableProEditor/Sources/TableProTextEngine/MarkedTextManager/MarkedTextManager.swift @@ -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. diff --git a/apple/Packages/TableProEditor/Sources/TableProTextEngine/TextView/TextView+NSTextInput.swift b/apple/Packages/TableProEditor/Sources/TableProTextEngine/TextView/TextView+NSTextInput.swift index b0cb219c6d..3e27252fc6 100644 --- a/apple/Packages/TableProEditor/Sources/TableProTextEngine/TextView/TextView+NSTextInput.swift +++ b/apple/Packages/TableProEditor/Sources/TableProTextEngine/TextView/TextView+NSTextInput.swift @@ -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 /// @@ -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) @@ -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) { @@ -127,8 +136,8 @@ 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. @@ -136,37 +145,77 @@ extension TextView: NSTextInputClient { /// - 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( @@ -174,11 +223,7 @@ extension TextView: NSTextInputClient { ) else { return } - - layoutManager.markedTextManager.removeAll() - layoutManager.setNeedsLayout() - needsLayout = true - inputContext?.discardMarkedText() + acceptComposition() } /// Unmarks the marked text. @@ -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. diff --git a/apple/Packages/TableProEditor/Sources/TableProTextEngine/TextView/TextView+ReplaceCharacters.swift b/apple/Packages/TableProEditor/Sources/TableProTextEngine/TextView/TextView+ReplaceCharacters.swift index 4dab9e0ad3..d5b856155e 100644 --- a/apple/Packages/TableProEditor/Sources/TableProTextEngine/TextView/TextView+ReplaceCharacters.swift +++ b/apple/Packages/TableProEditor/Sources/TableProTextEngine/TextView/TextView+ReplaceCharacters.swift @@ -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 diff --git a/apple/Packages/TableProEditor/Sources/TableProTextEngine/Utils/CEUndoManager.swift b/apple/Packages/TableProEditor/Sources/TableProTextEngine/Utils/CEUndoManager.swift index a28db0ee4e..730b98a2b7 100644 --- a/apple/Packages/TableProEditor/Sources/TableProTextEngine/Utils/CEUndoManager.swift +++ b/apple/Packages/TableProEditor/Sources/TableProTextEngine/Utils/CEUndoManager.swift @@ -70,6 +70,9 @@ public class CEUndoManager: UndoManager { guard !isDisabled, let textView else { return } + // An open composition is in the text but not in the history; accepting it first, as `NSTextView` does, keeps + // every inverse range pointing at the text it was recorded against. + textView.acceptComposition() guard let item = undoStack.popLast() else { NSSound.beep() @@ -101,6 +104,7 @@ public class CEUndoManager: UndoManager { guard !isDisabled, let textView else { return } + textView.acceptComposition() guard let item = redoStack.popLast() else { NSSound.beep() @@ -200,17 +204,43 @@ public class CEUndoManager: UndoManager { /// 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. + /// Calling this method while the manager is disabled or 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, - !isRedoing else { + guard let textStorage = textView?.textStorage, isRecording else { return } - let newMutation = Mutation(mutation: mutation, inverse: textStorage.inverseMutation(for: mutation)) + push(Mutation(mutation: mutation, inverse: textStorage.inverseMutation(for: mutation))) + } + + /// Records text already in the document as if it had just been typed there. + /// + /// An input method's composition stays out of the history while it is marked, since every keystroke rewrites it. + /// When it ends without a commit its text stays, and this makes that text an undoable insertion. + /// - Parameter ranges: Where the text sits now. They must not overlap. + public func registerInsertions(in ranges: [NSRange]) { + removeAllActions() + guard let textStorage = textView?.textStorage, isRecording else { + return + } + var length = textStorage.length - ranges.reduce(0) { $0 + $1.length } + for range in ranges.sorted(by: { $0.location < $1.location }) where !range.isEmpty { + let mutation = TextMutation( + insert: textStorage.substring(from: range) ?? "", + at: range.location, + limit: length + ) + length += range.length + push(Mutation(mutation: mutation, inverse: TextMutation(delete: range, limit: length))) + } + } + + private var isRecording: Bool { + !isDisabled && !isUndoing && !isRedoing + } + + private func push(_ newMutation: Mutation) { // We can continue a group if: // - A group exists // - We're not direct to break the current group diff --git a/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/CompositionTypingTests.swift b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/CompositionTypingTests.swift new file mode 100644 index 0000000000..b6f4b0670b --- /dev/null +++ b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/CompositionTypingTests.swift @@ -0,0 +1,95 @@ +// +// CompositionTypingTests.swift +// TableProEditorKitTests +// + +import AppKit +@testable import TableProEditorKit +import TableProTextEngine +import Testing + +@MainActor +struct CompositionTypingTests { + private static let notFound = NSRange(location: NSNotFound, length: 0) + + private func makeController(_ text: String, caret: Int) -> TextViewController { + let controller = Mock.loadedTextViewController(string: text) + controller.textView.selectionManager.setSelectedRange(NSRange(location: caret, length: 0)) + return controller + } + + private func type(_ text: String, in textView: TextView) { + textView.insertText(text, replacementRange: Self.notFound) + } + + private func compose(_ marked: String, committing commit: String, in textView: TextView) { + textView.setMarkedText( + marked, + selectedRange: NSRange(location: (marked as NSString).length, length: 0), + replacementRange: Self.notFound + ) + type(commit, in: textView) + } + + private func carets(of controller: TextViewController) -> [NSRange] { + controller.textView.selectionManager.textSelections.map(\.range) + } + + @Test(arguments: ["'", "\""]) + func deadKeyThenSpaceTypesOneQuote(quote: String) { + let controller = makeController("SELECT ", caret: 7) + + compose(quote, committing: quote, in: controller.textView) + + #expect(controller.textView.string == "SELECT " + quote) + #expect(carets(of: controller) == [NSRange(location: 8, length: 0)]) + } + + @Test(arguments: [("'", "'", "a"), ("\"", "\"", "a"), ("'", "é", "x"), ("(", "(", "a")]) + func committedCompositionTypesLikeTheKey(marked: String, commit: String, next: String) { + let typed = makeController("SELECT ", caret: 7) + type(commit, in: typed.textView) + type(next, in: typed.textView) + + let composed = makeController("SELECT ", caret: 7) + compose(marked, committing: commit, in: composed.textView) + type(next, in: composed.textView) + + #expect(composed.textView.string == typed.textView.string) + #expect(carets(of: composed) == carets(of: typed)) + } + + @Test + func cancelledDeadKeyKeepsTheClosingQuoteAfterIt() { + let controller = makeController("''", caret: 1) + + controller.textView.setMarkedText( + "'", + selectedRange: NSRange(location: 1, length: 0), + replacementRange: Self.notFound + ) + controller.textView.setMarkedText( + "", + selectedRange: NSRange(location: 0, length: 0), + replacementRange: Self.notFound + ) + + #expect(controller.textView.string == "''") + #expect(carets(of: controller) == [NSRange(location: 1, length: 0)]) + } + + @Test + func compositionAcceptedByUnmarkingKeepsTheClosingQuoteAfterIt() { + let controller = makeController("''", caret: 1) + + controller.textView.setMarkedText( + "'", + selectedRange: NSRange(location: 1, length: 0), + replacementRange: Self.notFound + ) + controller.textView.unmarkText() + + #expect(controller.textView.string == "'''") + #expect(controller.textView.hasMarkedText() == false) + } +} diff --git a/apple/Packages/TableProEditor/Tests/TableProTextEngineTests/CompositionUndoTests.swift b/apple/Packages/TableProEditor/Tests/TableProTextEngineTests/CompositionUndoTests.swift new file mode 100644 index 0000000000..ed24dad731 --- /dev/null +++ b/apple/Packages/TableProEditor/Tests/TableProTextEngineTests/CompositionUndoTests.swift @@ -0,0 +1,146 @@ +import AppKit +@testable import TableProTextEngine +import Testing + +@MainActor +struct CompositionUndoTests { + private static let notFound = NSRange(location: NSNotFound, length: 0) + + private func makeTextView(_ text: String, selection: NSRange) -> TextView { + let textView = TextView(string: text) + textView.selectionManager.setSelectedRange(selection) + return textView + } + + private func setMarked(_ text: String, in textView: TextView, replacing range: NSRange = notFound) { + textView.setMarkedText( + text, + selectedRange: NSRange(location: (text as NSString).length, length: 0), + replacementRange: range + ) + } + + private func undoAll(_ textView: TextView) { + var remaining = 10 + while textView.undoManager?.canUndo == true, remaining > 0 { + textView.undoManager?.undo() + remaining -= 1 + } + } + + @Test + func oneUndoRemovesACommittedDeadKey() { + let textView = makeTextView("ab", selection: NSRange(location: 1, length: 0)) + + setMarked("´", in: textView) + textView.insertText("é", replacementRange: Self.notFound) + textView.undoManager?.undo() + + #expect(textView.string == "ab") + #expect(textView.undoManager?.canUndo == false) + } + + @Test + func compositionStagesAreNotUndoSteps() { + let textView = makeTextView("ab", selection: NSRange(location: 1, length: 0)) + + for stage in ["k", "ka", "kan"] { + setMarked(stage, in: textView) + } + textView.insertText("缶", replacementRange: Self.notFound) + #expect(textView.string == "a缶b") + + textView.undoManager?.undo() + + #expect(textView.string == "ab") + #expect(textView.undoManager?.canUndo == false) + } + + @Test + func unmarkTextKeepsTheCompositionAsTypedText() { + let textView = makeTextView("ab", selection: NSRange(location: 1, length: 0)) + + setMarked("´", in: textView) + textView.unmarkText() + + #expect(textView.string == "a´b") + #expect(textView.hasMarkedText() == false) + #expect(textView.selectionManager.textSelections.map(\.range) == [NSRange(location: 2, length: 0)]) + + textView.undoManager?.undo() + #expect(textView.string == "ab") + } + + @Test + func compositionLeftByMovingTheCaretIsUndoable() { + let textView = makeTextView("abc", selection: NSRange(location: 1, length: 0)) + + setMarked("´", in: textView) + textView.selectionManager.setSelectedRange(NSRange(location: 4, length: 0)) + textView.unmarkTextIfNeeded() + #expect(textView.string == "a´bc") + #expect(textView.hasMarkedText() == false) + + textView.undoManager?.undo() + #expect(textView.string == "abc") + } + + @Test + func undoRestoresTheSelectionACompositionReplaced() { + let textView = makeTextView("ABCDE", selection: NSRange(location: 4, length: 1)) + + setMarked("´", in: textView) + textView.insertText("é", replacementRange: Self.notFound) + #expect(textView.string == "ABCDé") + + undoAll(textView) + #expect(textView.string == "ABCDE") + } + + @Test + func cancelledCompositionOverASelectionUndoesBackToTheSelection() { + let textView = makeTextView("ABCDE", selection: NSRange(location: 4, length: 1)) + + setMarked("´", in: textView) + setMarked("", in: textView) + #expect(textView.string == "ABCD") + + textView.undoManager?.undo() + #expect(textView.string == "ABCDE") + #expect(textView.undoManager?.canUndo == false) + } + + @Test + func commitAtTouchingSelectionsKeepsEveryCommit() { + let textView = TextView(string: "AB") + textView.selectionManager.setSelectedRanges([ + NSRange(location: 0, length: 1), + NSRange(location: 1, length: 1) + ]) + + setMarked("´", in: textView) + #expect(textView.string == "´´") + textView.insertText("é", replacementRange: Self.notFound) + #expect(textView.string == "éé") + + undoAll(textView) + #expect(textView.string == "AB") + } + + @Test + func compositionBegunAtAnExplicitRangeIsMarkedThere() { + let textView = makeTextView("hello world", selection: NSRange(location: 11, length: 0)) + + setMarked("かんじ", in: textView, replacing: NSRange(location: 6, length: 5)) + + #expect(textView.string == "hello かんじ") + #expect(textView.markedRange() == NSRange(location: 6, length: 3)) + + textView.insertText("漢字", replacementRange: Self.notFound) + #expect(textView.string == "hello 漢字") + #expect(textView.selectionManager.textSelections.map(\.range) == [NSRange(location: 8, length: 0)]) + + undoAll(textView) + #expect(textView.string == "hello world") + } +} diff --git a/apple/Packages/TableProEditor/Tests/TableProTextEngineTests/IMEInputTests.swift b/apple/Packages/TableProEditor/Tests/TableProTextEngineTests/IMEInputTests.swift index d3b5e78067..6ba00f7428 100644 --- a/apple/Packages/TableProEditor/Tests/TableProTextEngineTests/IMEInputTests.swift +++ b/apple/Packages/TableProEditor/Tests/TableProTextEngineTests/IMEInputTests.swift @@ -79,6 +79,35 @@ struct IMEInputTests { #expect(caret.range == NSRange(location: 8, length: 0)) } + @Test("A commit over part of the composition keeps the rest as typed text") + func commitOverPartOfTheCompositionKeepsTheRest() { + let textView = makeLaidOutTextView("alpha") + textView.selectionManager.setSelectedRange(NSRange(location: 5, length: 0)) + + typeMarkedCeshi(on: textView) + textView.insertText("测", replacementRange: NSRange(location: 5, length: 2)) + + #expect(textView.string == "alpha测shi") + #expect(textView.hasMarkedText() == false) + + while textView.undoManager?.canUndo == true { + textView.undoManager?.undo() + } + #expect(textView.string == "alpha") + } + + @Test("A commit range that names no position replaces the composition") + func commitWithAnUnusableRangeReplacesTheComposition() { + let textView = makeLaidOutTextView("alpha") + textView.selectionManager.setSelectedRange(NSRange(location: 5, length: 0)) + + typeMarkedCeshi(on: textView) + textView.insertText("测试", replacementRange: NSRange(location: 3, length: Int.max)) + + #expect(textView.string == "alpha测试") + #expect(textView.hasMarkedText() == false) + } + @Test("Marked-text state is cleared after a commit") func markedTextStateClearedAfterCommit() { let textView = makeLaidOutTextView("alpha\n\nbeta") diff --git a/apple/changelog.d/3579.fixed.1.md b/apple/changelog.d/3579.fixed.1.md new file mode 100644 index 0000000000..530996737e --- /dev/null +++ b/apple/changelog.d/3579.fixed.1.md @@ -0,0 +1 @@ +Undo after an accented or input method character left part of it behind or replayed each keystroke. diff --git a/apple/changelog.d/3579.fixed.2.md b/apple/changelog.d/3579.fixed.2.md new file mode 100644 index 0000000000..737b7bbedd --- /dev/null +++ b/apple/changelog.d/3579.fixed.2.md @@ -0,0 +1 @@ +Double, triple or Shift-clicking while composing input method text deleted that text. diff --git a/apple/changelog.d/3579.fixed.md b/apple/changelog.d/3579.fixed.md new file mode 100644 index 0000000000..756f0b14d8 --- /dev/null +++ b/apple/changelog.d/3579.fixed.md @@ -0,0 +1 @@ +A dead-key quote in the SQL editor inserted three quotes, or deleted the closing quote when cancelled.