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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- Fixed a crash on macOS 26 and later when the editor redrew a diagnostic underline or search highlight whose text had been edited away.
- Fixed a crash when an input method, dictation or Look Up asked the editor about text that had already been edited away. (#2339)
- The XLSX, MQL and SQL Import plugins linked to a documentation page that did not exist. They now point at Import & Export.

## [0.67.0] - 2026-08-21
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,22 @@ extension NSRange {
let end = Swift.min(Swift.max(self.max, 0), length)
return NSRange(location: start, length: Swift.max(0, end - start))
}

/// Returns the range resolved against a document of `length`, or `nil` when it names no
/// position in that document.
///
/// Use this for a range that came from outside the text view: an input service, an
/// accessibility client, or state stored before an edit. Those may send `NSNotFound`, a
/// negative value, or a length that overflows when added to the location, none of which
/// ``clamped(toLength:)`` can move inside the document, and the second of which traps when
/// `max` is computed.
func resolved(inDocumentOfLength length: Int) -> NSRange? {
guard location != NSNotFound,
location >= 0,
self.length >= 0,
location <= Int.max - self.length else {
return nil
}
return clamped(toLength: length)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,25 @@ class MarkedTextManager {
markedRanges.removeAll()
}

/// Resolves the stored ranges against a document of `length`, dropping any that name no
/// position in it and collapsing any that resolve to the same one.
///
/// An input session spans many callbacks and this object has no hook for edits made through
/// any other path, so an edit that shrinks the document leaves its ranges behind. Resolving
/// before they are used keeps the next keystroke of a composition computed against text that
/// still exists, rather than replacing at a position the document no longer has.
func resolveRanges(inDocumentOfLength length: Int) {
var resolved: [NSRange] = []
for range in markedRanges {
guard let clamped = range.resolved(inDocumentOfLength: length),
!resolved.contains(clamped) else {
continue
}
resolved.append(clamped)
}
markedRanges = resolved
}

/// Updates the stored marked ranges.
///
/// Two cases here:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ extension TextView: NSTextInputClient {
insertString = LineEnding.carriageReturnLineFeed.rawValue
}

replaceCharacters(in: replacementRanges, with: insertString)
replaceCharacters(in: resolvedReplacementRanges(replacementRanges), with: insertString)

selectionManager.textSelections.forEach { $0.suggestedXPos = nil }
}
Expand Down Expand Up @@ -85,6 +85,7 @@ extension TextView: NSTextInputClient {
@objc public func insertText(_ string: Any, replacementRange: NSRange) {
guard isEditable, let insertString = anyToString(string) else { return }

layoutManager.markedTextManager.resolveRanges(inDocumentOfLength: textStorage.length)
let markedRanges = layoutManager.markedTextManager.markedRanges
let hadMarkedText = !markedRanges.isEmpty

Expand Down Expand Up @@ -126,6 +127,7 @@ extension TextView: NSTextInputClient {
guard isEditable, let insertString = anyToString(string) else { return }
// Needs to insert text, but not notify the undo manager.
_undoManager?.disable()
layoutManager.markedTextManager.resolveRanges(inDocumentOfLength: textStorage.length)
let shouldInsert = layoutManager.markedTextManager.markedRanges.isEmpty

// Copy the text selections *before* we modify them.
Expand Down Expand Up @@ -172,6 +174,7 @@ extension TextView: NSTextInputClient {
@objc public func unmarkText() {
if layoutManager.markedTextManager.hasMarkedText {
_undoManager?.disable()
layoutManager.markedTextManager.resolveRanges(inDocumentOfLength: textStorage.length)
replaceCharacters(in: layoutManager.markedTextManager.markedRanges, with: "")
_undoManager?.enable()
layoutManager.markedTextManager.removeAll()
Expand Down Expand Up @@ -238,11 +241,49 @@ extension TextView: NSTextInputClient {
forProposedRange range: NSRange,
actualRange: NSRangePointer?
) -> NSAttributedString? {
let realRange = (textStorage.string as NSString).rangeOfComposedCharacterSequences(for: range)
actualRange?.pointee = .notFound
guard let realRange = composedRange(forProposedRange: range) else { return nil }
actualRange?.pointee = realRange
return textStorage.attributedSubstring(from: realRange)
}

/// Resolves a range handed to us by an input service against the document.
///
/// Returns `nil` when the range starts outside the document, which is the case
/// `NSTextInputClient` documents as having no answer. A range that starts inside it keeps its
/// position and gives up only the part that no longer exists, so a caret question still has
/// an answer and an empty result means "no characters there" rather than "no such place".
private func composedRange(forProposedRange range: NSRange) -> NSRange? {
let documentLength = textStorage.length
guard let clamped = range.resolved(inDocumentOfLength: documentLength),
clamped.location == range.location else {
return nil
}
guard !clamped.isEmpty else { return clamped }
return (textStorage.string as NSString).rangeOfComposedCharacterSequences(for: clamped)
}

/// Resolves the ranges an input service asked us to replace against the current document.
///
/// The ranges an input session works with are computed against the document as it was, and any
/// edit made through another path can leave them behind: marked-text bookkeeping keeps its own
/// ranges and has no hook for those edits. `replaceCharacters` registers undo before it
/// mutates, and building the inverse slices the storage, so a range that outruns the document
/// traps there rather than raising something catchable. Two ranges that resolve to the same
/// position would replace the same text twice, so they collapse to one.
internal func resolvedReplacementRanges(_ ranges: [NSRange]) -> [NSRange] {
let documentLength = textStorage.length
var resolved: [NSRange] = []
for range in ranges {
guard let clamped = range.resolved(inDocumentOfLength: documentLength),
!resolved.contains(clamped) else {
continue
}
resolved.append(clamped)
}
return resolved
}

/// Returns an attributed string representing the receiver's text storage.
/// - Returns: The attributed string of the receiver’s text storage.
@objc public func attributedString() -> NSAttributedString {
Expand All @@ -259,14 +300,21 @@ extension TextView: NSTextInputClient {
/// - Returns: The boundary rectangle for the given range of characters, in *screen* coordinates.
/// The rectangle’s size value can be negative if the text flows to the left.
@objc public func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect {
if actualRange != nil {
let realRange = (textStorage.string as NSString).rangeOfComposedCharacterSequences(for: range)
if realRange != range {
actualRange?.pointee = realRange
}
actualRange?.pointee = .notFound

// A range we cannot resolve still has to produce a usable rect: an input service places
// its candidate window, accent popover or dictation indicator here, and a zero rect puts
// all of them in the corner of the display. The end of the document is where the old code
// landed for those, because `rectForOffset` answers any offset past the end that way.
let offset: Int
if let realRange = composedRange(forProposedRange: range) {
actualRange?.pointee = realRange
offset = realRange.location
} else {
offset = textStorage.length
}

let localRect = (layoutManager.rectForOffset(range.location) ?? .zero)
let localRect = (layoutManager.rectForOffset(offset) ?? .zero)
let windowRect = convert(localRect, to: nil)
return window?.convertToScreen(windowRect) ?? .zero
}
Expand Down
Loading
Loading