allowsExpansionToolTips with wrapping NSTextField capped by maximumNumberOfLines

Hi AppKit team,

I'm trying to use an NSTextField in an NSTableView where the visible text wraps up to 3 lines, truncates after that, and then shows the full text in an expansion tooltip on hover.

The behavior I want is:

  • visible cell: wrapped text, capped at 3 lines
  • hover expansion tooltip: full wrapped text

I can get expansion tooltips to appear for non-wrapping text, but I haven't been able to get them to work for wrapped text capped with maximumNumberOfLines.

What is the recommended way to implement expansion tooltips for a wrapping, line-capped NSTextField?

Here is a minimal repro:

import AppKit

final class ViewController: NSViewController, NSTableViewDataSource, NSTableViewDelegate {
    let tableView = NSTableView()
    let text = String(repeating: "Very long wrapped text ", count: 40)

    override func viewDidLoad() {
        view = tableView
        tableView.addTableColumn(NSTableColumn())
        tableView.usesAutomaticRowHeights = true
        tableView.dataSource = self
        tableView.delegate = self
    }

    func numberOfRows(in tableView: NSTableView) -> Int { 1 }

    func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
        let tf = NSTextField(wrappingLabelWithString: text)
        tf.maximumNumberOfLines = 3
        tf.allowsExpansionToolTips = true
        tf.cell?.truncatesLastVisibleLine = true
        return tf
    }
}

Thank you.

Answered by Frameworks Engineer in 892312022

Thanks for the clear repro and goal that you're trying to achieve. This is a real limitation in NSTextField and not something you're missing in your setup.

Expansion tooltips for a text field come from NSTextFieldCell.expansionFrame(withFrame:in:). For non-wrapping (truncating) text it measures the text's natural width and offers a tooltip when the text is wider than the cell and for a wrapping field it instead measures the natural height (laying the text out at the cell's width with unbounded height) and offers a tooltip only when that exceeds the cell's height.

The catch is that this height measurement goes through the cell's internal string-drawing context, which reads maximumNumberOfLines directly off the text field. So once you set maximumNumberOfLines = 3, the "full" height it measures is itself capped at 3 lines, never exceeds the visible cell, and the method returns NSZeroRect so no tooltip. The same cap is applied when drawing the tooltip, so even a forced frame would clip to 3 lines. There's no property that lifts this.

An approach you can try is to subclass NSTextFieldCell and temporarily clear the line cap around both the measurement and the expansion draw. Because the drawing context reads maximumNumberOfLines live from the control view, a save/restore around super should be enough:

final class ExpandingWrapTextFieldCell: NSTextFieldCell {
    override func expansionFrame(withFrame cellFrame: NSRect, in view: NSView) -> NSRect {
        guard let field = controlView as? NSTextField, field.maximumNumberOfLines > 0 else {
            return super.expansionFrame(withFrame: cellFrame, in: view)
        }
        let saved = field.maximumNumberOfLines
        field.maximumNumberOfLines = 0          // measure the true full height
        defer { field.maximumNumberOfLines = saved }
        return super.expansionFrame(withFrame: cellFrame, in: view)
    }

    override func drawWithExpansionFrame(_ cellFrame: NSRect, in view: NSView) {
        guard let field = controlView as? NSTextField, field.maximumNumberOfLines > 0 else {
            return super.drawWithExpansionFrame(cellFrame, in: view)
        }
        let saved = field.maximumNumberOfLines
        field.maximumNumberOfLines = 0          // draw the full text into the tooltip
        defer { field.maximumNumberOfLines = saved }
        super.drawWithExpansionFrame(cellFrame, in: view)
    }
}

You keep the 3-line capped, truncated cell, and on hover the expansion tooltip lays out and draws the full wrapped text.

Alternatively if you'd rather not subclass, the pragmatic fallback is a plain toolTip with the full text though you lose the seamless cell-aligned overlay and get the standard delayed tooltip, but it's zero custom code.

Hopefully this helps.

Accepted Answer

Thanks for the clear repro and goal that you're trying to achieve. This is a real limitation in NSTextField and not something you're missing in your setup.

Expansion tooltips for a text field come from NSTextFieldCell.expansionFrame(withFrame:in:). For non-wrapping (truncating) text it measures the text's natural width and offers a tooltip when the text is wider than the cell and for a wrapping field it instead measures the natural height (laying the text out at the cell's width with unbounded height) and offers a tooltip only when that exceeds the cell's height.

The catch is that this height measurement goes through the cell's internal string-drawing context, which reads maximumNumberOfLines directly off the text field. So once you set maximumNumberOfLines = 3, the "full" height it measures is itself capped at 3 lines, never exceeds the visible cell, and the method returns NSZeroRect so no tooltip. The same cap is applied when drawing the tooltip, so even a forced frame would clip to 3 lines. There's no property that lifts this.

An approach you can try is to subclass NSTextFieldCell and temporarily clear the line cap around both the measurement and the expansion draw. Because the drawing context reads maximumNumberOfLines live from the control view, a save/restore around super should be enough:

final class ExpandingWrapTextFieldCell: NSTextFieldCell {
    override func expansionFrame(withFrame cellFrame: NSRect, in view: NSView) -> NSRect {
        guard let field = controlView as? NSTextField, field.maximumNumberOfLines > 0 else {
            return super.expansionFrame(withFrame: cellFrame, in: view)
        }
        let saved = field.maximumNumberOfLines
        field.maximumNumberOfLines = 0          // measure the true full height
        defer { field.maximumNumberOfLines = saved }
        return super.expansionFrame(withFrame: cellFrame, in: view)
    }

    override func drawWithExpansionFrame(_ cellFrame: NSRect, in view: NSView) {
        guard let field = controlView as? NSTextField, field.maximumNumberOfLines > 0 else {
            return super.drawWithExpansionFrame(cellFrame, in: view)
        }
        let saved = field.maximumNumberOfLines
        field.maximumNumberOfLines = 0          // draw the full text into the tooltip
        defer { field.maximumNumberOfLines = saved }
        super.drawWithExpansionFrame(cellFrame, in: view)
    }
}

You keep the 3-line capped, truncated cell, and on hover the expansion tooltip lays out and draws the full wrapped text.

Alternatively if you'd rather not subclass, the pragmatic fallback is a plain toolTip with the full text though you lose the seamless cell-aligned overlay and get the standard delayed tooltip, but it's zero custom code.

Hopefully this helps.

allowsExpansionToolTips with wrapping NSTextField capped by maximumNumberOfLines
 
 
Q