0

I have an non-editable NSTextField which I'm filling with NSTextAttachments, each of which is styled like a token. I now want to be able to mouseover a token and get a cancel button in the token, which will let me remove it from the list by pressing the button.

I have fallen at the first hurdle because I cannot figure out how to get mouse tracking to work. The NSTextAttachmentCellProtocol (used by NSTextAttachmentCell) declares wantsToTrackMouse(), which I've implemented and returned true from. But the documentation also claims that the default implementation returns true, so I shouldn't have to do that. Regardless, this method is never being called. I've tried both the simple implementation and the more complex one.

I have implemented trackMouse(with:in:of:untilMouseUp:), but this is also never being called.

This answer from a few years back claims that you won't get trackMouse events until you implement hitTest, so I tried using a super simple implementation of that:

override func hitTest(for event: NSEvent, in cellFrame: NSRect, of controlView: NSView) -> NSCell.HitResult {
    return NSCell.HitResult.trackableArea
}

This method is never called either.

Here's my code, with all of the token styling stripped out for simplicity:

import Cocoa

class TokenCellTest: NSTextAttachmentCell {
    
    static let titleMargin: CGFloat = 0
    
    override func cellSize() -> NSSize {
        let attribs = [NSAttributedString.Key.font: font ?? NSFont.systemFont(ofSize: 13)]
        let titleSize = (stringValue as NSString).size(withAttributes: attribs)
        return cellSize(forTitleSize: titleSize)
    }
    
    override func titleRect(forBounds rect: NSRect) -> NSRect {
        var bounds = rect
        
        bounds.size.width = max(bounds.size.width, TokenCellTest.titleMargin * 2 + bounds.size.height)
        bounds = bounds.insetBy(dx: TokenCellTest.titleMargin + bounds.size.height / 2, dy: 0)
        
        return bounds
    }
    
    override func draw(withFrame cellFrame: NSRect, in controlView: NSView?, characterIndex charIndex: Int) {
        guard let controlView = controlView else { return }
        
        if charIndex >= 0, let textView = controlView as? NSTextView {
            for rangeValue in textView.selectedRanges {
                let range = rangeValue.rangeValue
                guard NSLocationInRange(charIndex, range) else { continue }
                if textView.window?.isKeyWindow ?? false {
                    break
                }
            }
        }
        
        drawToken(with: cellFrame, in: controlView)
    }
    
    // MARK: - private methods
    
    private func cellSize(forTitleSize titleSize: NSSize) -> NSSize {
        var size = titleSize
        size.height += 1
        size.width += size.height + TokenCellTest.titleMargin * 2
        let rect = NSRect(origin: .zero, size: size)
        return NSIntegralRect(rect).size;
    }
    
    private func drawToken(with rect: NSRect, in view: NSView) {
        NSGraphicsContext.current?.saveGraphicsState()
        
        drawTitle(with: titleRect(forBounds: rect), in: view)
        
        NSGraphicsContext.current?.restoreGraphicsState()
    }
    
    private func drawTitle(with rect: NSRect, in view: NSView) {
        (stringValue as NSString).draw(in: rect)
    }
    
    
    // MARK: - Mouse tracking
    override func trackMouse(with theEvent: NSEvent, in cellFrame: NSRect, of controlView: NSView?, untilMouseUp flag: Bool) -> Bool {
        print("trackMouse")
        return true
    }

    override func wantsToTrackMouse() -> Bool {
        print("wantsToTrackMouse")
        return true
    }
    
    override func hitTest(for event: NSEvent, in cellFrame: NSRect, of controlView: NSView) -> NSCell.HitResult {
        print("hit test")
        return NSCell.HitResult.trackableArea
    }
}

The code to add these tokens to a text field:

let attachment = NSTextAttachment()

let cell = TokenCellTest(textCell: "Blue")
attachment.attachmentCell = cell

let attStr = NSAttributedString(attachment:attachment)
field.attributedStringValue = attStr

And here's what the tokens look like with this:

tokens in a text field

And no mouse tracking. :-(

I am using this approach, instead of an NSTokenField, because I wanted to draw the tokens myself, and it seemed like it might be simpler to just do all of that in an attachment cell. And indeed, that part is working great. But now that I want to add interactivity, I don't understand why the attachment cell's mouse handling methods aren't being called.

Ultimately, I'm expecting to need to declare a button cell inside the attachment cell and delegate the button cell drawing in the draw method. But I want the button to only show on hover, hence the token needs to know when the mouse is in its bounds. And I have a vague idea that the click event will need to be passed to the button cell, but I'm not certain how this will work either.

If there's a better way to create interactive, custom-drawn tokens in a non-editable view, I guess I'm all ears. This code looked promising, but it won't open on current versions of Xcode. And anyway, it doesn't touch token drawing itself. I took lots of inspiration from OEXTokenAttachmentCell so far, but that doesn't do mouse handling either.

Nick K9
  • 3,885
  • 1
  • 29
  • 62
  • 1
    From the documentation of `hitTest(for:in:of:)`: "Currently, it is called by some multi-cell views, such as NSTableView.". – Willeke Mar 29 '23 at 07:37
  • I think you have to send the mouse events to the attachments yourself. – Willeke Mar 29 '23 at 08:06
  • Well spotted, thanks. As for manually passing on mouse events, is [this](https://stackoverflow.com/a/15729814) the right way to do that? Would love a pointer to some sample code if you know of any. – Nick K9 Mar 29 '23 at 08:20
  • How about a collection view? – Willeke Mar 29 '23 at 09:50

0 Answers0