Mac NSTextInputClient implementation

KT <[email protected]> Thu, 26 Jun 2025 12:08:06 -0700 (PDT)
Newsgroups gmane.comp.lib.wxwindows.general
Message-ID <[email protected]>
I thought this might be helpful for someone. My application implements a 
fully custom text-editing interface. Of course the problem with that is 
that you don't get the Mac interface niceties like long-press accents, 
three-finger tap to lookup, proper IME positioning/handling, and dictation.

This addresses that. It's not a proper patch, so I haven't provided it as 
such (partly because I have a feeling I took some shortcuts that are not 
necessarily in keeping with wxMac design). But it's pretty easy to drop in.

Two mac/ wxWidgets source files have to be changed — window.h and window.mm — and 
then of course you have to implement things in application-side.


*window.h:*

Before the declaration of wxWindowMac, add:

typedef struct _wxNSRange {
    unsigned long location = ULONG_MAX;
    unsigned long length = 0;
} wxNSRange;
typedef wxNSRange* wxNSRangePtr;

Then, in the public section of wxWindowMac, add:

virtual void MacInsertText(wxString&, wxNSRange& /*replacementRange*/) {}
virtual bool MacHasMarkedText()            { return false; }
virtual wxNSRange MacMarkedRange()         { return wxNSRange(); }
virtual wxNSRange MacSelectedRange()       { return wxNSRange(); }
virtual void MacSetMarkedText(const wxString&, const wxNSRange& 
/*selectedRange*/, const wxNSRange& /*replacementRange*/) {}
virtual void MacUnmarkText() {}
virtual wxString MacAttributedSubstringForProposedRange(const wxNSRange&, 
wxNSRangePtr& actualRange)
    { actualRange = NULL; return wxEmptyString; }
// Using wx coordinates:
virtual int MacCharacterIndexForPoint(const wxPoint&)  { return UINT_MAX; }
virtual wxRect MacFirstRectForCharacterRange(const wxNSRange&, 
wxNSRangePtr& actualRange)
    { actualRange = NULL; return wxRect(); }


*window.mm:*

The section beginning @implementation wxNSView(TextInput) contains stub 
functions that can be replaced in their entirety with:

@implementation wxNSView(TextInput)

// Conversions
wxNSRange NSRangeToWx(const NSRange nsrange)
{
    wxNSRange wxrange;
    wxrange.location = nsrange.location;
    wxrange.length = nsrange.length;
    return wxrange;
}
NSRange wxNSRangeToNS(const wxNSRange wxrange)
{
    return NSMakeRange(wxrange.location, wxrange.length);
}

void wxOSX_insertText(NSView* self, SEL _cmd, NSString* text);

- (void)insertText:(id)aString replacementRange:(NSRange)replacementRange
{
    wxLogDebug("insertText: '%s' at %s", wxStringWithNSString(aString), 
wxStringWithNSString(NSStringFromRange(replacementRange)));
    wxWidgetCocoaImpl* impl = (wxWidgetCocoaImpl* ) 
wxWidgetImpl::FindFromWXWidget( self );
    if (impl)
    {
        wxString str = wxStringWithNSString(aString);
        wxNSRange wxrange = NSRangeToWx(replacementRange);
        // MacInsertText() may modify str or wxrange
        impl->GetWXPeer()->MacInsertText(str, wxrange);
        if (str.IsEmpty())
            return;
        aString = wxNSStringWithWxString(str);
        replacementRange = wxNSRangeToNS(wxrange);
    }

    wxOSX_insertText(self, @selector(insertText:), aString);
}

- (void)doCommandBySelector:(SEL)aSelector
{
    wxLogDebug("doCommandBySelector: %s", 
wxStringWithNSString(NSStringFromSelector(aSelector)));
    wxWidgetCocoaImpl* impl = (wxWidgetCocoaImpl* ) 
wxWidgetImpl::FindFromWXWidget( self );
    if (impl)
        impl->doCommandBySelector(aSelector, self, _cmd);
}

- (void)setMarkedText:(id)aString selectedRange:(NSRange)selectedRange 
replacementRange:(NSRange)replacementRange
{
    if ([aString isKindOfClass:[NSAttributedString class]])
    {
        // wxString can only use the string portion of NSAttributedString
        aString = [aString string];
    }
    wxLogDebug("setMarkedText: '%s' at %s, %s", 
wxStringWithNSString(aString), 
wxStringWithNSString(NSStringFromRange(selectedRange)), 
wxStringWithNSString(NSStringFromRange(replacementRange)));
    wxWidgetCocoaImpl* impl = (wxWidgetCocoaImpl* ) 
wxWidgetImpl::FindFromWXWidget( self );
    if (impl)
    {
        impl->GetWXPeer()->MacSetMarkedText(wxStringWithNSString(aString), 
NSRangeToWx(selectedRange), NSRangeToWx(replacementRange));
    }
}

- (void)unmarkText
{
    wxLogDebug("unmarkText");
    wxWidgetCocoaImpl* impl = (wxWidgetCocoaImpl* ) 
wxWidgetImpl::FindFromWXWidget( self );
    if (impl)
    {
        impl->GetWXPeer()->MacUnmarkText();
    }
}

- (NSRange)selectedRange
{
    wxLogDebug("selectedRange");
    wxWidgetCocoaImpl* impl = (wxWidgetCocoaImpl* ) 
wxWidgetImpl::FindFromWXWidget( self );
    if (impl)
    {
        return wxNSRangeToNS(impl->GetWXPeer()->MacSelectedRange());
    }
    
    return NSMakeRange(NSNotFound, 0);
}

- (NSRange)markedRange
{
    wxLogDebug("markedRange");
    wxWidgetCocoaImpl* impl = (wxWidgetCocoaImpl* ) 
wxWidgetImpl::FindFromWXWidget( self );
    if (impl)
    {
        return wxNSRangeToNS(impl->GetWXPeer()->MacMarkedRange());
    }

    return NSMakeRange(NSNotFound, 0);
}
    

- (BOOL)hasMarkedText
{
    wxLogDebug("hasMarkedText");
    wxWidgetCocoaImpl* impl = (wxWidgetCocoaImpl* ) 
wxWidgetImpl::FindFromWXWidget( self );
    if (impl)
    {
        return impl->GetWXPeer()->MacHasMarkedText() ? YES : NO;
    }

    return NO;
}

- (NSAttributedString *)attributedSubstringForProposedRange:(NSRange)aRange 
actualRange:(NSRangePointer)actualRange
{
    wxLogDebug("attributedSubstringForProposedRange: %s", 
wxStringWithNSString(NSStringFromRange(aRange)));
    wxWidgetCocoaImpl* impl = (wxWidgetCocoaImpl* ) 
wxWidgetImpl::FindFromWXWidget( self );
    if (impl)
    {
        wxNSRange actual = NSRangeToWx(*actualRange);
        wxNSRangePtr actualPtr = &actual;
        NSString *html = 
wxNSStringWithWxString(impl->GetWXPeer()->MacAttributedSubstringForProposedRange(NSRangeToWx(aRange), 
actualPtr));
        NSData *htmlData = [html dataUsingEncoding:NSUTF8StringEncoding];
        NSDictionary *options = @{NSDocumentTypeDocumentAttribute: 
NSHTMLTextDocumentType, NSCharacterEncodingDocumentAttribute: 
@(NSUTF8StringEncoding)};
        NSMutableAttributedString *aStr = [[NSMutableAttributedString 
alloc] initWithHTML:htmlData options: options documentAttributes:nil];
        return aStr;
    }

    return nil;
}

- (NSArray*)validAttributesForMarkedText
{
    return nil;
}

- (NSRect)firstRectForCharacterRange:(NSRange)aRange 
actualRange:(NSRangePointer)actualRange
{
    wxLogDebug("firstRectForCharacterRange: %s", 
wxStringWithNSString(NSStringFromRange(aRange)));
    wxWidgetCocoaImpl* impl = (wxWidgetCocoaImpl* ) 
wxWidgetImpl::FindFromWXWidget( self );
    if (impl)
    {
        wxNSRange actual = NSRangeToWx(*actualRange);
        wxNSRangePtr actualPtr = &actual;
        wxRect rect = 
impl->GetWXPeer()->MacFirstRectForCharacterRange(NSRangeToWx(aRange), 
actualPtr);
        wxPoint pt = rect.GetTopLeft();
        NSPoint nspt = wxToNSPoint(NULL, pt);
        NSRect nsrect = NSMakeRect(nspt.x, nspt.y, rect.width, rect.height);
        return nsrect;
    }

    return NSMakeRect(0, 0, 0, 0);
}

- (NSUInteger)characterIndexForPoint:(NSPoint)aPoint
{
    wxLogDebug("characterIndexForPoint", 
wxStringWithNSString(NSStringFromPoint(aPoint)));
    wxWidgetCocoaImpl* impl = (wxWidgetCocoaImpl* ) 
wxWidgetImpl::FindFromWXWidget( self );
    if (impl)
    {
        wxPoint pt = wxFromNSPoint(NULL, aPoint);
        int index = impl->GetWXPeer()->MacCharacterIndexForPoint(pt);
        return index;
    }

    return NSNotFound;
}

@end // wxNSView(TextInput)


*Implementation:*

You'll need to implement most or all of the new Mac*() methods from 
window.h in your wxWindow-derived class.

If the application does not currently have a way to convert a given text 
position into an absolute position from the start of the document, you'll 
need to do that, as all NSRange parameters are given as such.

An important thing to know is that marked text and selected text are not 
the same. Selected text is what a user has highlighted. Marked text is text 
that NSInputClient is showing prior to committing (i.e., via IME input or 
dictation). You'll need to keep track of it (and display it) separately.

Note that insertText() will send an invalid NSRange when typing normally, 
but a valid range when replacing marked text (i.e., for a long-press 
accent, etc.).

attributedSubstringForProposedRange returns a piece of text given the 
proposed range, such as the highlighted word when three-finger tapping. 
You'll need to determine the highlighted text.

My implementation of attributedSubstringForProposedRange() uses a simple 
HTML string.  Note that if you're styling the text for that highlighting in 
the returned HTML that it seems that only system font faces seem to work, 
not user ones.

characterIndexForPoint() returns the 0-based index in the document text of 
a given coordinate point. firstRectForCharacterRange() returns the rect for 
a given range, used for positioning things like three-finger tap 
highlighting, the accent or IME panel, etc. firstRectForCharacterRange() will 
send a zero-length NSRange with a valid location when positioning the IME 
panel or dictation. (Only the position of the rect seems to matter.)

For convenience application-side, MacCharacterIndexForPoint() and 
MacFirstRectForCharacterRange() deal in wx coordinates (wxPoint and wxRect).

Additionally, the API seems to provide no notification that an IME panel, 
dictation instance, etc. is being dismissed so you'll have to figure that 
out on your own. I can't see a non-client-specific way to do any of that, 
as it's pretty dependent on the program's behaviour. Chromium, for 
instance, I think says that to get around this they just clear marked text 
on every keystroke and let it get re-marked. I don't quite do that, since 
it doesn't work for my application flow, but I do call MacUnmarkText() in a 
number of places.

There are probably a few more NSTextInputClient quirks I'm missing.

-- 
Please read https://www.wxwidgets.org/support/mlhowto.htm before posting.
--- 
You received this message because you are subscribed to the Google Groups "wx-users" group.
To unsubscribe from this group and stop receiving emails from it, send an email to [email protected].
To view this discussion visit https://groups.google.com/d/msgid/wx-users/790804db-0bb4-4937-9255-e510266eeff8n%40googlegroups.com.