Do you need help on a specific subject? Use the contact form (Request a blog entry) on the right hand side.


Adding tooltips to changing NSView content

In the App I am currently working on I have an array of strings in an NSView and some of these lines may have one or more errors in them. The error's are red underlined, but I needed a way to inform the user about the nature of the error. Tooltips are the best way imo, the user hovers the mouse over the underlined word, pauses a little, and up pops the error reason.
Next the user may edit the line and remove the cause of the error, the line is displayed again without the underlining, and of course the tooltip must disappear also.

So how to add and remove those tooltips?

It turns out that each NSView already the ToolTip management build in. We only have to create tooltips and add them to the view when we need them. And of course remove them when no longer needed.

This does mean that we need way to associate the tooltip with error no N in line X, and when that error disappears, the tooltip must be removed.

I found the easiest solution for me was to add the tooltip to the data model. Yes, Ugh.. I can hear you..

But the alternative is to build a paralel data structure in my subclassed NSView to (the lines in) the data model and somehow keep those two synchronised. A double Ugh..

So I added the tooltip to the data model.

In objective-C the tooltip can consist of an NSString, in Swift that did not work for me, probably because String is bridged to NSString, but the tooltip needs to inherit from NSObject. Or maybe there is another reason. Anyhow this issue is moot as I needed to track the tooltips and thus had to create a class that encompasses the tooltip:

    class ToolTip: NSObject {
        var tip: String
        var tag: NSToolTipTag?
        init(tip: String) {
            self.tip = tip
        override func view(view: NSView,
            stringForToolTip tag: NSToolTipTag,
            point: NSPoint,
            userData data: UnsafeMutablePointer<Void>) -> String {
                return self.tip

    typealias ToolTips = Array<ToolTip>

In each line in the datamodel I added some storage for the tooltips

    // Note: Never ever handle the tooltips from within the data model.
    // The tooltips are managed by the NSView that displays this line.

    var toolTips: ToolTips = ToolTips()

In MyDocumentView (that is subclassed from NSView) I added the following code to the code that draws a single line. Note that drawLine is called from drawRect.

    /// Draws the line for the given index.
    private func drawLine(index: Int) {
        // Note: the index can refer to a non-existing line!
        // Strategy:
        // 1. Determine the background color.
        // 2. Draw the backgroud color.
        // 3. Add the text.
        // 4. ...
        // 5. ...
        // 6. Add error marker and tooltip.
        // 7. ...
        let context = NSGraphicsContext.currentContext()!.CGContext
        // 1. Set the background color
        if ((index % 2) == 0) {
            CGContextSetFillColorWithColor(context, normalBackgroundColor)
        } else {
            CGContextSetFillColorWithColor(context, alternateBackgroundColor)
        // Create the rectangle for the background color
        let backgroundRect = rectForLine(index)
        // 2. Draw the background
        CGContextFillRect(context, backgroundRect)
        CGContextDrawPath(context, kCGPathFill)
        // Draw the text, insertion point or selection only for existing lines
        if index < document.lines.count {

            // 3. Add the text
            let xOffset = MARGIN_BEFORE_LINE
            let yOffset = lineHeight * CGFloat(index) - MARGIN_BELOW_LINE
            (document.lines[index].string as NSString).drawAtPoint(CGPointMake(xOffset, yOffset), withAttributes: document.fontDict)
            // 4...
            // 5...
            // 6. Add error markers and tooltips.
            // Remove old tooltips associated with this line
            for toolTip in document.lines[index].toolTips {
            document.lines[index].toolTips.removeAll(keepCapacity: true)

            for err in document.lines[index].errorData {
                let y = lineHeight * CGFloat(index) - MARGIN_BELOW_LINE
                let x = document.lines[index].offsetForIndex(err.range.startIndex)
                        + MARGIN_BEFORE_LINE
                let width = (document.lines[index].offsetForIndex(err.range.endIndex)
                            - document.lines[index].offsetForIndex(err.range.startIndex))
                // Do the red underlining
                CGContextSetAllowsAntialiasing(context, false); // Ensure hairline drawing
                CGContextSetRGBStrokeColor(context, 1.0, 0.0, 0.0, 1.0)
                CGContextSetLineWidth(context, 1.0)
                CGContextMoveToPoint(context, x, y + lineHeight - 1.0)
                CGContextAddLineToPoint(context, x + width, y + lineHeight - 1.0)
                CGContextDrawPath(context, kCGPathStroke)
                CGContextSetAllowsAntialiasing(context, true) // Disable hairlines
                // Add the new tooltip
                let toolTip = ToolTip(tip: err.reason)
                let toolTipRect = NSRect(x: x, y: y, width: width, height: lineHeight)
                toolTip.tag = addToolTipRect(toolTipRect, owner: toolTip, userData: nil)
        // 7...


Obviously this is not the complete code to draw the line, but everything related to the tooltips is in here.
Of note is the following: Before drawing the red underlining and registering the tooltips, the old tooltips for the line are removed from the view and the data model. Then new tooltips are created and added to the view and the datamodel. The data model only stores the tooltips in order to be able to remove them later. This way, when the user edits the line we can be sure that any old error related tooltips are gone from the view. The user thus gets the feedback that the error was corrected.
The other thing to note is that the tag value generated by the view is later used to remove the tooltip from the view.
Oh, one last thing: this view uses the flipped Y coordinates: override var flipped: Bool { get { return true }}

Happy coding...

Did this help?, then please help out a small independent.
If you decide that you want to make a small donation, you can do so by clicking this
link: a cup of coffee ($2) or use the popup on the right hand side for different amounts.
Payments will be processed by PayPal, receiver will be sales at balancingrock dot nl
Bitcoins will be gladly accepted at: 1GacSREBxPy1yskLMc9de2nofNv2SNdwqH

We don't get the world we wish for... we get the world we pay for.

No comments:

Post a Comment