macOS: Take Emoji from CharacterPalette (revised)

Viewed 334

(This is a revised question - including answer - following on from macOS: Take emoji from characterPalette which describes the problems encountered in more detail)

Background/use case

I have an app where, instead of creating and maintaining an icon library, I let users type an emoji as a placeholder graphic. This works beautifully within the context of my app, but I am not happy with the input mechanism I use.

Problem

I would like to simplify this so I open the characterPalette, select an emoji, and display it either as the button's StringValue or in a Label (=non-editable NSTextField).

This does not seem possible. Unlike NSColorPanel or NSFontPanel, the characterPanel is not exposed to the Cocoa framework, so I cannot take its selectedValue, set its action, or catch a notification. The documentation for orderFrontCharacterPalette simply says Opens the character palette which ... is not helpful.

Attempted solutions and problems encountered

I tried to work with making my receiver the firstResponder, but unlike NSTextView, NSTextField cannot process emoji. I found a workaround using an NSTextView with an NSBox in front, making it the firstResponder, and using NSApp.orderFrontCharacterPalette(sender)but found that under various circumstances which all seem to involve an extra drawing call – setting the button's title, showing a label in SystemFont Mini size (regular size worked fine) the CharacterPalette will open (=the system menu now offers 'Hide Emoji & Symbols') without being displayed. (This persists until the application closes, even if you try to open the CharacterPalette through the regular menu/shortcut)

For the partial solution involving NSTextInputClient (the no-show seems to be a persistent bug), see answer below.

2 Answers

The emoji picker needs a minimal implementation of NSTextInputClient. For example a button:

class MyButton: NSButton, NSTextInputClient {
    
    override var acceptsFirstResponder: Bool {
        get {
            return true
        }
    }
    
    override func becomeFirstResponder() -> Bool {
        return true
    }
    
    override func resignFirstResponder() -> Bool {
        return true
    }

    func insertText(_ string: Any, replacementRange: NSRange) {
        // this method is called when the user selects an emoji
        if let string = string as? String {
            self.title = string
        }
    }
    
    func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
    }
    
    func unmarkText() {
    }
    
    func selectedRange() -> NSRange {
        return NSMakeRange(0, 0)
    }
    
    func markedRange() -> NSRange {
        return NSMakeRange(NSNotFound, 0)
    }
    
    func hasMarkedText() -> Bool {
        return false
    }
    
    func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? {
        return nil
    }
    
    func validAttributesForMarkedText() -> [NSAttributedString.Key] {
        return []
    }
    
    func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect {
        // the emoji picker uses the returned rect to position itself
        var rect = self.bounds
        rect.origin.x = NSMidX(rect)
        rect.size.width = 0
        return self.window!.convertToScreen(self.convert(rect, to:nil))
    }
    
    func characterIndex(for point: NSPoint) -> Int {
        return 0
    }
    
}

NSTextInputClient needs a NSTextInputContext. NSView returns a context from inputContext if the class conforms to NSTextInputClient unless isEditable is implemented and returns false. A label doesn't return a NSTextInputContext, the solution is to override inputContext:

class MyTextField: NSTextField, NSTextInputClient {
    
    var myInputContext : NSTextInputContext?

    override var inputContext: NSTextInputContext? {
        get {
            if myInputContext == nil {
                myInputContext = NSTextInputContext(client:self)
            }
            return myInputContext
        }
    }

    // and the same methods as the button, set self.stringValue instead of self.title in insertText(_:replacementRange:)

 }

Willeke pointed me at NSTextInputClient which has provided the best solution so far. Apple's only example is in ObjectiveC, convoluted, and overly complex for what I was trying to do, so I am reproducing my code here.

Caveat: this is not a full implementation of NSTextInputClient, just enough to capture emoji input

I have created an NSButton subclass:

class TextReceiverButton: NSButton, NSTextInputClient {
    //specific methods
    func setButtonTitle(_ string: String?){
        self.title = string ??  
    }

//NSTextInputClient methods    

    func insertText(_ string: Any, replacementRange: NSRange) {
       let receivedText = string as? String
       setButtonTitle(receivedText)
    }
    

    func validAttributesForMarkedText() -> [NSAttributedString.Key] {
        return [.font, .paragraphStyle, .writingDirection] 
    }

//Omitted: For anything else that wants a value, I return NSMakeRange(0, 0)/NSRect.zero or 0 as well as false for marked text and nil for attributed substring
    }

(If you add the protocol to your class, it will offer stubs for the other methods)

The full set for NSAttributedString.Key is

[.font, .foregroundColor, .glyphInfo, .kern, .ligature, .link, .markedClauseSegment, .obliqueness, .paragraphStyle, .shadow, .spellingState,  .strikethroughColor, .strikethroughStyle, .strokeColor, .strokeWidth, .superscript, .textAlternatives, .textEffect, .toolTip, .underlineColor, .underlineStyle, .verticalGlyphForm, .writingDirection]

(I have tested the short form with simple and composite emoji and nothing else seems necessary.)

The button's action is

@IBAction func displayEmojiInButton(_ sender: Any) {
    NSApp.orderFrontCharacterPalette(self)
view.window?.makeFirstResponder(textReceiverButton)
}

Problems/Bugs

The NSTextInputClient document says 'you can subclass NSView' and Apple's code turns an NSView into a fully functional (receiving and drawing) text view class (I can't built it, but I assume it worked). So theoretically, you should be able to use the same code for NSTextField, which also ultimately inherits from NSView.

However, it turns out that NSTextField displays the 'CharacterPalette allegedly opens but never displays' bug I talked about earlier; though it does work with NSView. (I have not tested this further). Furthermore, NSTextInputClient is not a complete replacement for NSTextView: it does not receive input from the keyboard viewer. (See Willecke's answer/comment for explanation/solution to these).

Verdict

NSApp.orderFrontCharacterPalette(self) fails 95% of the time when called from a view in the vincinity of a tab view (in splitView next to TabViewController, embedded in TabViewController), so while this code may be correct, it's also useless a lot of the time, at least under 10.13.

Related