diff --git a/Sources/TextBoxInput.swift b/Sources/TextBoxInput.swift index d2d6309817c..34e7e4359b2 100644 --- a/Sources/TextBoxInput.swift +++ b/Sources/TextBoxInput.swift @@ -3636,6 +3636,7 @@ final class TextBoxInputTextView: NSTextView { } override func insertText(_ insertString: Any, replacementRange: NSRange) { + let replacementRange = sanitizedTextStorageReplacementRange(replacementRange) queueAutomaticAttachmentFileCleanup(in: replacementRange) super.insertText(insertString, replacementRange: replacementRange) flushAutomaticAttachmentFileCleanup() @@ -3643,7 +3644,12 @@ final class TextBoxInputTextView: NSTextView { } override func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { - super.setMarkedText(string, selectedRange: selectedRange, replacementRange: replacementRange) + let markedTextLength = Self.textInputStringLength(string) + super.setMarkedText( + string, + selectedRange: Self.sanitizedMarkedTextSelectionRange(selectedRange, markedTextLength: markedTextLength), + replacementRange: sanitizedTextStorageReplacementRange(replacementRange) + ) onMarkedTextStateChanged(hasMarkedText()) } @@ -3657,6 +3663,35 @@ final class TextBoxInputTextView: NSTextView { flushAutomaticAttachmentFileCleanup() } + private func sanitizedTextStorageReplacementRange(_ range: NSRange) -> NSRange { + guard range.location != NSNotFound else { return range } + return Self.sanitizedRange(range, upperBound: attributedString().length) + } + + private static func sanitizedRange(_ range: NSRange, upperBound: Int) -> NSRange { + guard range.location != NSNotFound else { return range } + let upperBound = max(0, upperBound) + let location = min(max(0, range.location), upperBound) + let length = min(max(0, range.length), upperBound - location) + return NSRange(location: location, length: length) + } + + private static func sanitizedMarkedTextSelectionRange(_ range: NSRange, markedTextLength: Int) -> NSRange { + let markedTextLength = max(0, markedTextLength) + guard range.location != NSNotFound else { + return NSRange(location: markedTextLength, length: 0) + } + return sanitizedRange(range, upperBound: markedTextLength) + } + + private static func textInputStringLength(_ string: Any) -> Int { + if let attributed = string as? NSAttributedString { + return attributed.length + } + let plain = (string as? String) ?? String(describing: string) + return (plain as NSString).length + } + override func copy(_ sender: Any?) { if copySelectedAttachments(to: .general) { return diff --git a/cmuxTests/TextBoxMentionCompletionTests.swift b/cmuxTests/TextBoxMentionCompletionTests.swift index 0e45e98c3f5..0355d78dd0e 100644 --- a/cmuxTests/TextBoxMentionCompletionTests.swift +++ b/cmuxTests/TextBoxMentionCompletionTests.swift @@ -165,6 +165,32 @@ struct TextBoxMentionCompletionTests { )) } + @Test + func testTextBoxInsertTextClampsStaleIMERangeAtUTF16End() { + let textView = TextBoxInputTextView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) + textView.string = "日本語" + textView.setSelectedRange(NSRange(location: 3, length: 0)) + + textView.insertText("、", replacementRange: NSRange(location: 3, length: 1)) + + #expect(textView.string == "日本語、") + #expect(textView.selectedRange() == NSRange(location: 4, length: 0)) + } + + @Test + func testTextBoxSetMarkedTextNormalizesMissingSelectedRangeToMarkedTextEnd() { + let textView = TextBoxInputTextView(frame: NSRect(x: 0, y: 0, width: 320, height: 30)) + + textView.setMarkedText( + "日本語", + selectedRange: NSRange(location: NSNotFound, length: 0), + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + + #expect(textView.string == "日本語") + #expect(textView.selectedRange() == NSRange(location: 3, length: 0)) + } + @Test func testTextBoxPublishesCommittedIMETextBeforeClearingMarkedState() { var text = ""