2

I have a NSTextField with a sentence in it. I want to find a rect (ideally) or position for each word in the sentence to do things in those positions (outside of the NSTextField).

Doing this in a NSTextView/UITextView seems achievable with NSLayoutManager.boundingRectForGlyphRange, but without the NSLayoutManager that NSTextView (and UITextView) have it seems a bit more challenging.

What is the best way for me to find the position of a given word in a NSTextField?

Community
  • 1
  • 1
glenstorey
  • 5,134
  • 5
  • 39
  • 71
  • This answer for UILabel seems relevant to NSTextField as well http://stackoverflow.com/questions/21004609/how-to-draw-a-border-around-a-paragraph-in-uilabel – davecom Jan 07 '16 at 05:58

2 Answers2

9

It requires one barely-documented bit of magic and another completely undocumented bit. Here's Objective-C code. Don't have it handy in Swift, sorry.

NSRect textBounds = [textField.cell titleRectForBounds:textField.bounds];

NSTextContainer* textContainer = [[NSTextContainer alloc] init];
NSLayoutManager* layoutManager = [[NSLayoutManager alloc] init];
NSTextStorage* textStorage = [[NSTextStorage alloc] init];
[layoutManager addTextContainer:textContainer];
[textStorage addLayoutManager:layoutManager];
textContainer.lineFragmentPadding = 2;
layoutManager.typesetterBehavior = NSTypesetterBehavior_10_2_WithCompatibility;

textContainer.containerSize = textBounds.size;
[textStorage beginEditing];
textStorage.attributedString = textField.attributedStringValue;
[textStorage endEditing];

NSUInteger count;
NSRectArray rects = [layoutManager rectArrayForCharacterRange:matchRange
                                 withinSelectedCharacterRange:matchRange
                                              inTextContainer:textContainer
                                                    rectCount:&count];
for (NSUInteger i = 0; i < count; i++)
{
    NSRect rect = NSOffsetRect(rects[i], textBounds.origin.x, textBounds.origin.y);
    rect = [textField convertRect:rect toView:self];
    // do something with rect
}

The typesetterBehavior is documented here. The lineFragmentPadding was determined empirically.

Depending on exactly what you're planning to do with the rectangles, you may wish to pass { NSNotFound, 0 } as the selected character range.

For efficiency, you generally want to keep the text objects around instead of instantiating them every time. You just set the text container's containerSize and the text storage's attributedString to the appropriate values each time.

Ken Thomases
  • 88,520
  • 7
  • 116
  • 154
  • Thank you - I'll give this a go and come back to you once I swift it. So tempting to jump back into ObjC land! – glenstorey Jan 08 '16 at 00:43
  • Great! Thank you this worked well. I had to change .attributedString to `textStorage.appendAttributedString` and I used `layoutManager.enumerateEnclosingRectsForGlyphRange` instead of `rectArrayForCharacterRange` - but I think these changes might be just because of the base SDK I'm using. – glenstorey Jan 09 '16 at 23:33
  • FYI, I have been using this happily for many years, but using `NSTypesetterBehavior_10_2_WithCompatibility` seems to have stopped working with `layoutManager.enumerateEnclosingRectsForGlyphRange` on macOS Monterey. I am now using `NSTypesetterLatestBehavior`, which seems to be working fine. – MrMage Dec 03 '21 at 10:23
  • This is missing some properties that needs to be copied over for correct calculation. For example macOS 10.15 introduced lineBreakStrategy for NSTextField but to copy that over, you need to insert it into the NSAttributedString's NSParagraphStyle attribute. And if NSFont is only set on the NSTextField then it also needs to be set on the NSAttributedString – Cyberbeni Apr 07 '22 at 11:31
2

According the Ken Thomases answer. I made a adaptation for Swift 4.2

    guard let textFieldCell = textField.cell,
        let textFieldCellBounds = textFieldCell.controlView?.bounds else{
        return
    }
    let textBounds = textFieldCell.titleRect(forBounds: textFieldCellBounds)
    let textContainer = NSTextContainer()
    let layoutManager = NSLayoutManager()
    let textStorage = NSTextStorage()

    layoutManager.addTextContainer(textContainer)
    textStorage.addLayoutManager(layoutManager)

    layoutManager.typesetterBehavior = NSLayoutManager.TypesetterBehavior.behavior_10_2_WithCompatibility

    textContainer.containerSize = textBounds.size
    textStorage.beginEditing()
    textStorage.setAttributedString(textFieldCell.attributedStringValue)
    textStorage.endEditing()

    let rangeCharacters = (textFieldCell.stringValue as NSString).range(of: "string")

    var count = 0
    let rects: NSRectArray = layoutManager.rectArray(forCharacterRange: rangeCharacters,
                                                    withinSelectedCharacterRange: rangeCharacters,
                                                    in: textContainer,
                                                    rectCount: &count)!

    for i in 0...count {
        var rect = NSOffsetRect(rects[i], textBounds.origin.x, textBounds.origin.y)
        rect = textField.convert(rect, to: self.view)
       // do something with rect
    }

All Credits to Ken Thomases

pointum
  • 2,987
  • 24
  • 31
Renato Ioshida
  • 421
  • 3
  • 6