diff options
Diffstat (limited to 'src/plugins/platforms/cocoa/qnsview_complextext.mm')
-rw-r--r-- | src/plugins/platforms/cocoa/qnsview_complextext.mm | 755 |
1 files changed, 520 insertions, 235 deletions
diff --git a/src/plugins/platforms/cocoa/qnsview_complextext.mm b/src/plugins/platforms/cocoa/qnsview_complextext.mm index 48cea12a14..d7f8f4baf0 100644 --- a/src/plugins/platforms/cocoa/qnsview_complextext.mm +++ b/src/plugins/platforms/cocoa/qnsview_complextext.mm @@ -1,315 +1,600 @@ -/**************************************************************************** -** -** Copyright (C) 2018 The Qt Company Ltd. -** Contact: https://www.qt.io/licensing/ -** -** This file is part of the plugins of the Qt Toolkit. -** -** $QT_BEGIN_LICENSE:LGPL$ -** Commercial License Usage -** Licensees holding valid commercial Qt licenses may use this file in -** accordance with the commercial license agreement provided with the -** Software or, alternatively, in accordance with the terms contained in -** a written agreement between you and The Qt Company. For licensing terms -** and conditions see https://www.qt.io/terms-conditions. For further -** information use the contact form at https://www.qt.io/contact-us. -** -** GNU Lesser General Public License Usage -** Alternatively, this file may be used under the terms of the GNU Lesser -** General Public License version 3 as published by the Free Software -** Foundation and appearing in the file LICENSE.LGPL3 included in the -** packaging of this file. Please review the following information to -** ensure the GNU Lesser General Public License version 3 requirements -** will be met: https://www.gnu.org/licenses/lgpl-3.0.html. -** -** GNU General Public License Usage -** Alternatively, this file may be used under the terms of the GNU -** General Public License version 2.0 or (at your option) the GNU General -** Public license version 3 or any later version approved by the KDE Free -** Qt Foundation. The licenses are as published by the Free Software -** Foundation and appearing in the file LICENSE.GPL2 and LICENSE.GPL3 -** included in the packaging of this file. Please review the following -** information to ensure the GNU General Public License requirements will -** be met: https://www.gnu.org/licenses/gpl-2.0.html and -** https://www.gnu.org/licenses/gpl-3.0.html. -** -** $QT_END_LICENSE$ -** -****************************************************************************/ +// Copyright (C) 2021 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only // This file is included from qnsview.mm, and only used to organize the code -@implementation QNSView (ComplexTextAPI) +@implementation QNSView (ComplexText) -- (void)cancelComposingText +// ------------- Text insertion ------------- + +- (QObject*)focusObject { - if (m_composingText.isEmpty()) - return; + // The text input system may still hold a reference to our QNSView, + // even after QCocoaWindow has been destructed, delivering text input + // events to us, so we need to guard for this situation explicitly. + if (!m_platformWindow) + return nullptr; - if (m_composingFocusObject) { - QInputMethodQueryEvent queryEvent(Qt::ImEnabled); - if (QCoreApplication::sendEvent(m_composingFocusObject, &queryEvent)) { - if (queryEvent.value(Qt::ImEnabled).toBool()) { - QInputMethodEvent e; - QCoreApplication::sendEvent(m_composingFocusObject, &e); - } + return m_platformWindow->window()->focusObject(); +} + +/* + Inserts the given text, potentially replacing existing text. + + The text input management system calls this as a result of: + + - A normal key press, via [NSView interpretKeyEvents:] or + [NSInputContext handleEvent:] + + - An input method finishing (confirming) composition + + - Pressing a key in the Keyboard Viewer panel + + - Confirming an inline input area (accent popup e.g.) + + \a replacementRange refers to the existing text to replace. + Under normal circumstances this is {NSNotFound, 0}, and the + implementation should replace either the existing marked text, + the current selection, or just insert the text at the current + cursor location. +*/ +- (void)insertText:(id)text replacementRange:(NSRange)replacementRange +{ + qCDebug(lcQpaKeys).nospace() << "Inserting \"" << text << "\"" + << ", replacing range " << replacementRange; + + if (m_composingText.isEmpty()) { + // The input method may have transformed the incoming key event + // to text that doesn't match what the original key event would + // have produced, for example when 'Pinyin - Simplified' does smart + // replacement of quotes. If that's the case we can't rely on + // handleKeyEvent for sending the text. + auto *currentEvent = NSApp.currentEvent; + NSString *eventText = currentEvent.type == NSEventTypeKeyDown + || currentEvent.type == NSEventTypeKeyUp + ? currentEvent.characters : nil; + + if ([text isEqualToString:eventText]) { + // We do not send input method events for simple text input, + // and instead let handleKeyEvent send the key event. + qCDebug(lcQpaKeys) << "Ignoring text insertion for simple text"; + m_sendKeyEvent = true; + return; } } - m_composingText.clear(); - m_composingFocusObject = nullptr; -} + if (queryInputMethod(self.focusObject)) { + QInputMethodEvent inputMethodEvent; -- (void)unmarkText -{ - if (!m_composingText.isEmpty()) { - if (QObject *fo = m_platformWindow->window()->focusObject()) { - QInputMethodQueryEvent queryEvent(Qt::ImEnabled); - if (QCoreApplication::sendEvent(fo, &queryEvent)) { - if (queryEvent.value(Qt::ImEnabled).toBool()) { - QInputMethodEvent e; - e.setCommitString(m_composingText); - QCoreApplication::sendEvent(fo, &e); - } - } + const bool isAttributedString = [text isKindOfClass:NSAttributedString.class]; + QString commitString = QString::fromNSString(isAttributedString ? [text string] : text); + + // Ensure we have a valid replacement range + replacementRange = [self sanitizeReplacementRange:replacementRange]; + + // Qt's QInputMethodEvent has different semantics for the replacement + // range than AppKit does, so we need to sanitize the range first. + auto [replaceFrom, replaceLength] = [self inputMethodRangeForRange:replacementRange]; + + if (replaceFrom == NSNotFound) { + qCWarning(lcQpaKeys) << "Failed to compute valid replacement range for text insertion"; + inputMethodEvent.setCommitString(commitString); + } else { + qCDebug(lcQpaKeys) << "Replacing from" << replaceFrom << "with length" << replaceLength + << "based on replacement range" << replacementRange; + inputMethodEvent.setCommitString(commitString, replaceFrom, replaceLength); } + + QCoreApplication::sendEvent(self.focusObject, &inputMethodEvent); } + m_composingText.clear(); m_composingFocusObject = nullptr; } -@end - -@implementation QNSView (ComplexText) - - (void)insertNewline:(id)sender { Q_UNUSED(sender); - m_resendKeyEvent = true; -} -- (void)doCommandBySelector:(SEL)aSelector -{ - [self tryToPerform:aSelector with:self]; + if (!m_platformWindow) + return; + + // Depending on the input method, pressing enter may + // result in simply dismissing the input method editor, + // without confirming the composition. In other cases + // it may confirm the composition as well. And in some + // cases the IME will produce an explicit new line, which + // brings us here. + + // Semantically, the input method has asked us to insert + // a newline, and we should do so via an QInputMethodEvent, + // either directly or via [self insertText:@"\r"]. This is + // also how NSTextView handles the command. But, if we did, + // we would bypass all the code in Qt (and clients) that + // assume that pressing the return key results in a key + // event, for example the QLineEdit::returnPressed logic. + // To ensure that clients will still see the Qt::Key_Return + // key event, we send it as a normal key event. + + // But, we can not fall back to handleKeyEvent for this, + // as the original key event may have text that reflects + // the combination of the inserted text and the newline, + // e.g. "~\r". We have already inserted the composition, + // so we need to follow up with a single newline event. + + KeyEvent newlineEvent(m_currentlyInterpretedKeyEvent ? + m_currentlyInterpretedKeyEvent : NSApp.currentEvent); + newlineEvent.type = QEvent::KeyPress; + + const bool isEnter = newlineEvent.modifiers & Qt::KeypadModifier; + newlineEvent.key = isEnter ? Qt::Key_Enter : Qt::Key_Return; + newlineEvent.text = isEnter ? QLatin1Char(kEnterCharCode) + : QLatin1Char(kReturnCharCode); + newlineEvent.nativeVirtualKey = isEnter ? quint32(kVK_ANSI_KeypadEnter) + : quint32(kVK_Return); + + qCDebug(lcQpaKeys) << "Inserting newline via" << newlineEvent; + newlineEvent.sendWindowSystemEvent(m_platformWindow->window()); } -- (void)insertText:(id)aString replacementRange:(NSRange)replacementRange +// ------------- Text composition ------------- + +/* + Updates the composed text, potentially replacing existing text. + + The NSTextInputClient protocol refers to composed text as "marked", + since it is "marked differently from the selection, using temporary + attributes that affect only display, not layout or storage."" + + The concept maps to the preeditString of our QInputMethodEvent. + + \a selectedRange refers to the part of the marked text that + is considered selected, for example when composing text with + multiple clause segments (Hiragana - Kana e.g.). + + \a replacementRange refers to the existing text to replace. + Under normal circumstances this is {NSNotFound, 0}, and the + implementation should replace either the existing marked text, + the current selection, or just insert the text at the current + cursor location. But when initiating composition of existing + committed text (Hiragana - Kana e.g.), the range will be valid. +*/ +- (void)setMarkedText:(id)text selectedRange:(NSRange)selectedRange replacementRange:(NSRange)replacementRange { - Q_UNUSED(replacementRange); + qCDebug(lcQpaKeys).nospace() << "Marking \"" << text << "\"" + << " with selected range " << selectedRange + << ", replacing range " << replacementRange; + + const bool isAttributedString = [text isKindOfClass:NSAttributedString.class]; + QString preeditString = QString::fromNSString(isAttributedString ? [text string] : text); + + QList<QInputMethodEvent::Attribute> preeditAttributes; + + // The QInputMethodEvent::Cursor specifies that the length + // determines whether the cursor is visible or not, but uses + // logic opposite of that of native AppKit application, where + // the cursor is visible if there's no selection, and hidden + // if there's a selection. Instead of passing on the length + // directly we need to inverse the logic. + const bool showCursor = !selectedRange.length; + preeditAttributes << QInputMethodEvent::Attribute( + QInputMethodEvent::Cursor, selectedRange.location, showCursor); + + // QInputMethodEvent::Selection unfortunately doesn't apply to the + // preedit text, and QInputMethodEvent::Cursor which does, doesn't + // support setting a selection. Until we've introduced attributes + // that allow us to propagate the preedit selection semantically + // we resort to styling the selection via the TextFormat attribute, + // so that the preedit selection is visible to the user. + QTextCharFormat selectionFormat; + auto *platformTheme = QGuiApplicationPrivate::platformTheme(); + auto *systemPalette = platformTheme->palette(); + selectionFormat.setBackground(systemPalette->color(QPalette::Highlight)); + preeditAttributes << QInputMethodEvent::Attribute( + QInputMethodEvent::TextFormat, + selectedRange.location, selectedRange.length, + selectionFormat); + + int index = 0; + int composingLength = preeditString.length(); + while (index < composingLength) { + NSRange range = NSMakeRange(index, composingLength - index); + + static NSDictionary *defaultMarkedTextAttributes = []{ + NSTextView *textView = [[NSTextView new] autorelease]; + return [textView.markedTextAttributes retain]; + }(); + + NSDictionary *attributes = isAttributedString + ? [text attributesAtIndex:index longestEffectiveRange:&range inRange:range] + : defaultMarkedTextAttributes; + + qCDebug(lcQpaKeys) << "Decorating range" << range << "based on" << attributes; + QTextCharFormat format; - if (m_sendKeyEvent && m_composingText.isEmpty() && [aString isEqualToString:m_inputSource]) { - // don't send input method events for simple text input (let handleKeyEvent send key events instead) - return; - } + if (NSNumber *underlineStyle = attributes[NSUnderlineStyleAttributeName]) { + format.setFontUnderline(true); + NSUnderlineStyle style = underlineStyle.integerValue; + if (style & NSUnderlineStylePatternDot) + format.setUnderlineStyle(QTextCharFormat::DotLine); + else if (style & NSUnderlineStylePatternDash) + format.setUnderlineStyle(QTextCharFormat::DashUnderline); + else if (style & NSUnderlineStylePatternDashDot) + format.setUnderlineStyle(QTextCharFormat::DashDotLine); + if (style & NSUnderlineStylePatternDashDotDot) + format.setUnderlineStyle(QTextCharFormat::DashDotDotLine); + else + format.setUnderlineStyle(QTextCharFormat::SingleUnderline); + + // Unfortunately QTextCharFormat::UnderlineStyle does not distinguish + // between NSUnderlineStyle{Single,Thick,Double}, which is used by CJK + // input methods to highlight the selected clause segments. + } + if (NSColor *underlineColor = attributes[NSUnderlineColorAttributeName]) + format.setUnderlineColor(qt_mac_toQColor(underlineColor)); + if (NSColor *foregroundColor = attributes[NSForegroundColorAttributeName]) + format.setForeground(qt_mac_toQColor(foregroundColor)); + if (NSColor *backgroundColor = attributes[NSBackgroundColorAttributeName]) + format.setBackground(qt_mac_toQColor(backgroundColor)); + + if (format != QTextCharFormat()) { + preeditAttributes << QInputMethodEvent::Attribute( + QInputMethodEvent::TextFormat, range.location, range.length, format); + } - QString commitString; - if ([aString length]) { - if ([aString isKindOfClass:[NSAttributedString class]]) { - commitString = QString::fromCFString(reinterpret_cast<CFStringRef>([aString string])); - } else { - commitString = QString::fromCFString(reinterpret_cast<CFStringRef>(aString)); - }; + index = range.location + range.length; } - if (QObject *fo = m_platformWindow->window()->focusObject()) { - QInputMethodQueryEvent queryEvent(Qt::ImEnabled); - if (QCoreApplication::sendEvent(fo, &queryEvent)) { - if (queryEvent.value(Qt::ImEnabled).toBool()) { - QInputMethodEvent e; - e.setCommitString(commitString); - QCoreApplication::sendEvent(fo, &e); - // prevent handleKeyEvent from sending a key event - m_sendKeyEvent = false; + + // Ensure we have a valid replacement range + replacementRange = [self sanitizeReplacementRange:replacementRange]; + + // Qt's QInputMethodEvent has different semantics for the replacement + // range than AppKit does, so we need to sanitize the range first. + auto [replaceFrom, replaceLength] = [self inputMethodRangeForRange:replacementRange]; + + // Update the composition, now that we've computed the replacement range + m_composingText = preeditString; + + if (QObject *focusObject = self.focusObject) { + m_composingFocusObject = focusObject; + if (queryInputMethod(focusObject)) { + QInputMethodEvent event(preeditString, preeditAttributes); + if (replaceLength > 0) { + // The input method may extend the preedit into already + // committed text. If so, we need to replace existing text + // by committing an empty string. + qCDebug(lcQpaKeys) << "Replacing from" << replaceFrom << "with length" + << replaceLength << "based on replacement range" << replacementRange; + event.setCommitString(QString(), replaceFrom, replaceLength); } + QCoreApplication::sendEvent(focusObject, &event); } } +} - m_composingText.clear(); - m_composingFocusObject = nullptr; +- (NSArray<NSString *> *)validAttributesForMarkedText +{ + return @[ + NSUnderlineColorAttributeName, + NSUnderlineStyleAttributeName, + NSForegroundColorAttributeName, + NSBackgroundColorAttributeName + ]; } -- (void)setMarkedText:(id)aString selectedRange:(NSRange)selectedRange replacementRange:(NSRange)replacementRange +- (BOOL)hasMarkedText { - Q_UNUSED(replacementRange); - QString preeditString; - - QList<QInputMethodEvent::Attribute> attrs; - attrs<<QInputMethodEvent::Attribute(QInputMethodEvent::Cursor, selectedRange.location + selectedRange.length, 1, QVariant()); - - if ([aString isKindOfClass:[NSAttributedString class]]) { - // Preedit string has attribution - preeditString = QString::fromCFString(reinterpret_cast<CFStringRef>([aString string])); - int composingLength = preeditString.length(); - int index = 0; - // Create attributes for individual sections of preedit text - while (index < composingLength) { - NSRange effectiveRange; - NSRange range = NSMakeRange(index, composingLength-index); - NSDictionary *attributes = [aString attributesAtIndex:index - longestEffectiveRange:&effectiveRange - inRange:range]; - NSNumber *underlineStyle = [attributes objectForKey:NSUnderlineStyleAttributeName]; - if (underlineStyle) { - QColor clr (Qt::black); - NSColor *color = [attributes objectForKey:NSUnderlineColorAttributeName]; - if (color) { - clr = qt_mac_toQColor(color); - } - QTextCharFormat format; - format.setFontUnderline(true); - format.setUnderlineColor(clr); - attrs<<QInputMethodEvent::Attribute(QInputMethodEvent::TextFormat, - effectiveRange.location, - effectiveRange.length, - format); - } - index = effectiveRange.location + effectiveRange.length; - } + return !m_composingText.isEmpty(); +} + +/* + Returns the range of marked text or {cursorPosition, 0} if there's none. + + This maps to the location and length of the current preedit (composited) string. + + The returned range measures from the start of the receiver’s text storage, + that is, from 0 to the document length. +*/ +- (NSRange)markedRange +{ + if (auto queryResult = queryInputMethod(self.focusObject, Qt::ImAbsolutePosition)) { + int absoluteCursorPosition = queryResult.value(Qt::ImAbsolutePosition).toInt(); + + // The cursor position as reflected by Qt::ImAbsolutePosition is not + // affected by the offset of the cursor in the preedit area. That means + // that when composing text, the cursor position stays the same, at the + // preedit insertion point, regardless of where the cursor is positioned within + // the preedit string by the QInputMethodEvent::Cursor attribute. This means + // we can use the cursor position to determine the range of the marked text. + + // The NSTextInputClient documentation says {NSNotFound, 0} should be returned if there + // is no marked text, but in practice NSTextView seems to report {cursorPosition, 0}, + // so we do the same. + return NSMakeRange(absoluteCursorPosition, m_composingText.length()); } else { - // No attributes specified, take only the preedit text. - preeditString = QString::fromCFString(reinterpret_cast<CFStringRef>(aString)); + return {NSNotFound, 0}; } +} - if (attrs.isEmpty()) { - QTextCharFormat format; - format.setFontUnderline(true); - attrs<<QInputMethodEvent::Attribute(QInputMethodEvent::TextFormat, - 0, preeditString.length(), format); - } +/* + Confirms the marked (composed) text. - m_composingText = preeditString; + The marked text is accepted as if it had been inserted normally, + and the preedit string is cleared. - if (QObject *fo = m_platformWindow->window()->focusObject()) { - m_composingFocusObject = fo; - QInputMethodQueryEvent queryEvent(Qt::ImEnabled); - if (QCoreApplication::sendEvent(fo, &queryEvent)) { - if (queryEvent.value(Qt::ImEnabled).toBool()) { - QInputMethodEvent e(preeditString, attrs); - QCoreApplication::sendEvent(fo, &e); - // prevent handleKeyEvent from sending a key event - m_sendKeyEvent = false; - } + If there is no marked text this method has no effect. +*/ +- (void)unmarkText +{ + // FIXME: Match cancelComposingText in early exit and focus object handling + + qCDebug(lcQpaKeys) << "Unmarking" << m_composingText + << "for focus object" << m_composingFocusObject; + + if (!m_composingText.isEmpty()) { + QObject *focusObject = self.focusObject; + if (queryInputMethod(focusObject)) { + QInputMethodEvent e; + e.setCommitString(m_composingText); + QCoreApplication::sendEvent(focusObject, &e); } } + + m_composingText.clear(); + m_composingFocusObject = nullptr; } -- (BOOL)hasMarkedText +/* + Cancels composition. + + The marked text is discarded, and the preedit string is cleared. + + If there is no marked text this method has no effect. +*/ +- (void)cancelComposingText { - return (m_composingText.isEmpty() ? NO: YES); + if (m_composingText.isEmpty()) + return; + + qCDebug(lcQpaKeys) << "Canceling composition" << m_composingText + << "for focus object" << m_composingFocusObject; + + if (queryInputMethod(m_composingFocusObject)) { + QInputMethodEvent e; + QCoreApplication::sendEvent(m_composingFocusObject, &e); + } + + m_composingText.clear(); + m_composingFocusObject = nullptr; } -- (NSAttributedString *)attributedSubstringForProposedRange:(NSRange)aRange actualRange:(NSRangePointer)actualRange +// ------------- Key binding command handling ------------- + +- (void)doCommandBySelector:(SEL)selector { - Q_UNUSED(actualRange); - QObject *fo = m_platformWindow->window()->focusObject(); - if (!fo) - return nil; - QInputMethodQueryEvent queryEvent(Qt::ImEnabled | Qt::ImCurrentSelection); - if (!QCoreApplication::sendEvent(fo, &queryEvent)) - return nil; - if (!queryEvent.value(Qt::ImEnabled).toBool()) - return nil; + // Note: if the selector cannot be invoked, then doCommandBySelector: + // should not pass this message up the responder chain (nor should it + // call super, as the NSResponder base class would in that case pass + // the message up the responder chain, which we don't want). We will + // pass the originating key event up the responder chain if applicable. + + qCDebug(lcQpaKeys) << "Trying to perform command" << selector; + if (![self tryToPerform:selector with:self]) { + m_sendKeyEvent = true; + + if (![NSStringFromSelector(selector) hasPrefix:@"insert"]) { + // The text input system determined that the key event was not + // meant for text insertion, and instead asked us to treat it + // as a (possibly noop) command. This typically happens for key + // events with either ⌘ or ⌃, function keys such as F1-F35, + // arrow keys, etc. We reflect that when sending the key event + // later on, by removing the text from the event, so that the + // event does not result in text insertion on the client side. + m_sendKeyEventWithoutText = true; + } + } +} - QString selectedText = queryEvent.value(Qt::ImCurrentSelection).toString(); - if (selectedText.isEmpty()) - return nil; +// ------------- Various text properties ------------- - QCFString string(selectedText.mid(aRange.location, aRange.length)); - const NSString *tmpString = reinterpret_cast<const NSString *>((CFStringRef)string); - return [[[NSAttributedString alloc] initWithString:const_cast<NSString *>(tmpString)] autorelease]; -} +/* + Returns the range of selected text, or {cursorPosition, 0} if there's none. -- (NSRange)markedRange + The returned range measures from the start of the receiver’s text storage, + that is, from 0 to the document length. +*/ +- (NSRange)selectedRange { - NSRange range; - if (!m_composingText.isEmpty()) { - range.location = 0; - range.length = m_composingText.length(); + if (auto queryResult = queryInputMethod(self.focusObject, + Qt::ImCursorPosition | Qt::ImAbsolutePosition | Qt::ImAnchorPosition)) { + + // Unfortunately the Qt::InputMethodQuery values are all relative + // to the start of the current editing block (paragraph), but we + // need them in absolute values relative to the entire text. + // Luckily we have one property, Qt::ImAbsolutePosition, that + // we can use to compute the offset. + int cursorPosition = queryResult.value(Qt::ImCursorPosition).toInt(); + int absoluteCursorPosition = queryResult.value(Qt::ImAbsolutePosition).toInt(); + int absoluteOffset = absoluteCursorPosition - cursorPosition; + + int anchorPosition = absoluteOffset + queryResult.value(Qt::ImAnchorPosition).toInt(); + int selectionStart = anchorPosition >= absoluteCursorPosition ? absoluteCursorPosition : anchorPosition; + int selectionEnd = selectionStart == anchorPosition ? absoluteCursorPosition : anchorPosition; + int selectionLength = selectionEnd - selectionStart; + + // Note: The cursor position as reflected by these properties are not + // affected by the offset of the cursor in the preedit area. That means + // that when composing text, the cursor position stays the same, at the + // preedit insertion point, regardless of where the cursor is positioned within + // the preedit string by the QInputMethodEvent::Cursor attribute. + + // The NSTextInputClient documentation says {NSNotFound, 0} should be returned if there is no + // selection, but in practice NSTextView seems to report {cursorPosition, 0}, so we do the same. + return NSMakeRange(selectionStart, selectionLength); } else { - range.location = NSNotFound; - range.length = 0; + return {NSNotFound, 0}; } - return range; } -- (NSRange)selectedRange +/* + Returns an attributed string derived from the given range + in the underlying focus object's text storage. + + Input methods may call this with a proposed range that is + out of bounds. For example, the InkWell text input service + may ask for the contents of the text input client that extends + beyond the document's range. To remedy this we always compute + the intersection between the proposed range and the available + text. + + If the intersection is completely outside of the available text + this method returns nil. +*/ +- (NSAttributedString *)attributedSubstringForProposedRange:(NSRange)range actualRange:(NSRangePointer)actualRange { - NSRange selectedRange = {0, 0}; - - QObject *fo = m_platformWindow->window()->focusObject(); - if (!fo) - return selectedRange; - QInputMethodQueryEvent queryEvent(Qt::ImEnabled | Qt::ImCurrentSelection); - if (!QCoreApplication::sendEvent(fo, &queryEvent)) - return selectedRange; - if (!queryEvent.value(Qt::ImEnabled).toBool()) - return selectedRange; - - QString selectedText = queryEvent.value(Qt::ImCurrentSelection).toString(); - - if (!selectedText.isEmpty()) { - selectedRange.location = 0; - selectedRange.length = selectedText.length(); + if (auto queryResult = queryInputMethod(self.focusObject, + Qt::ImAbsolutePosition | Qt::ImTextBeforeCursor | Qt::ImTextAfterCursor)) { + const int absoluteCursorPosition = queryResult.value(Qt::ImAbsolutePosition).toInt(); + const QString textBeforeCursor = queryResult.value(Qt::ImTextBeforeCursor).toString(); + const QString textAfterCursor = queryResult.value(Qt::ImTextAfterCursor).toString(); + + // The documentation doesn't say whether the marked text should be included + // in the available text, but observing NSTextView shows that this is the + // case, so we follow suit. + const QString availableText = textBeforeCursor + m_composingText + textAfterCursor; + const NSRange availableRange = NSMakeRange(absoluteCursorPosition - textBeforeCursor.length(), + availableText.length()); + + const NSRange intersectedRange = NSIntersectionRange(range, availableRange); + if (actualRange) + *actualRange = intersectedRange; + + if (!intersectedRange.length) + return nil; + + NSString *substring = QStringView(availableText).mid( + intersectedRange.location - availableRange.location, + intersectedRange.length).toNSString(); + + return [[[NSAttributedString alloc] initWithString:substring] autorelease]; + + } else { + return nil; } - return selectedRange; } -- (NSRect)firstRectForCharacterRange:(NSRange)aRange actualRange:(NSRangePointer)actualRange +- (NSRect)firstRectForCharacterRange:(NSRange)range actualRange:(NSRangePointer)actualRange { - Q_UNUSED(aRange); + Q_UNUSED(range); Q_UNUSED(actualRange); - QObject *fo = m_platformWindow->window()->focusObject(); - if (!fo) - return NSZeroRect; - - QInputMethodQueryEvent queryEvent(Qt::ImEnabled); - if (!QCoreApplication::sendEvent(fo, &queryEvent)) - return NSZeroRect; - if (!queryEvent.value(Qt::ImEnabled).toBool()) + QWindow *window = m_platformWindow ? m_platformWindow->window() : nullptr; + if (window && queryInputMethod(window->focusObject())) { + QRect cursorRect = qApp->inputMethod()->cursorRectangle().toRect(); + cursorRect.moveBottomLeft(window->mapToGlobal(cursorRect.bottomLeft())); + return QCocoaScreen::mapToNative(cursorRect); + } else { return NSZeroRect; - - // The returned rect is always based on the internal cursor. - QRect mr = qApp->inputMethod()->cursorRectangle().toRect(); - mr.moveBottomLeft(m_platformWindow->window()->mapToGlobal(mr.bottomLeft())); - return QCocoaScreen::mapToNative(mr); + } } -- (NSUInteger)characterIndexForPoint:(NSPoint)aPoint +- (NSUInteger)characterIndexForPoint:(NSPoint)point { // We don't support cursor movements using mouse while composing. - Q_UNUSED(aPoint); + Q_UNUSED(point); return NSNotFound; } -- (NSArray<NSString *> *)validAttributesForMarkedText +/* + Returns the window level of the text input. + + This allows the input method to place its input panel + above the text input. +*/ +- (NSInteger)windowLevel { - if (!m_platformWindow) - return nil; + // The default level assumed by input methods is NSFloatingWindowLevel, + // but our NSWindow level could be higher than that for many reasons, + // including being set via QWindow::setFlags() or directly on the + // NSWindow, or because we're embedded into a native view hierarchy. + // Return the actual window level to account for this. + auto level = m_platformWindow ? m_platformWindow->nativeWindow().level + : NSNormalWindowLevel; + + // The logic above only covers our own window though. In some cases, + // such as when a completer is active, the text input has a lower + // window level than another window that's also visible, and we don't + // want the input panel to be sandwiched between these two windows. + // Account for this by explicitly using NSPopUpMenuWindowLevel as + // the minimum window level, which corresponds to the highest level + // one can get via QWindow::setFlags(), except for Qt::ToolTip. + return qMax(level, NSPopUpMenuWindowLevel); +} - if (m_platformWindow->window() != QGuiApplication::focusWindow()) - return nil; +// ------------- Helper functions ------------- - QObject *fo = m_platformWindow->window()->focusObject(); - if (!fo) - return nil; +/* + Sanitizes the replacement range, ensuring it's valid. - QInputMethodQueryEvent queryEvent(Qt::ImEnabled); - if (!QCoreApplication::sendEvent(fo, &queryEvent)) - return nil; - if (!queryEvent.value(Qt::ImEnabled).toBool()) - return nil; + If \a range is not valid the range of the current + marked text will be used. + + If there's no marked text the range of the current + selection will be used. + + If there's no selection the range will be {cursorPosition, 0}. +*/ +- (NSRange)sanitizeReplacementRange:(NSRange)range +{ + if (range.location != NSNotFound) + return range; // Use as is + + // If the replacement range is not specified we are expected to compute + // the range ourselves, based on the current state of the input context. - // Support only underline color/style. - return @[NSUnderlineColorAttributeName, NSUnderlineStyleAttributeName]; + const auto markedRange = [self markedRange]; + if (markedRange.location != NSNotFound) + return markedRange; + else + return [self selectedRange]; } -- (void)textInputContextKeyboardSelectionDidChangeNotification:(NSNotification *)textInputContextKeyboardSelectionDidChangeNotification +/* + Computes the QInputMethodEvent commit string range, + based on the NSTextInputClient replacement range. + + The two APIs have different semantics. +*/ +- (std::pair<long long, long long>)inputMethodRangeForRange:(NSRange)range { - Q_UNUSED(textInputContextKeyboardSelectionDidChangeNotification); - if (([NSApp keyWindow] == self.window) && self.window.firstResponder == self) { - if (QCocoaInputContext *ic = qobject_cast<QCocoaInputContext *>(QCocoaIntegration::instance()->inputContext())) - ic->updateLocale(); - } + long long replaceFrom = range.location; + long long replaceLength = range.length; + + const auto markedRange = [self markedRange]; + const auto selectedRange = [self selectedRange]; + + // The QInputMethodEvent replacement start is relative to the start + // of the marked text (the location of the preedit string). + if (markedRange.location != NSNotFound) + replaceFrom -= markedRange.location; + else + replaceFrom = 0; + + // The replacement length of QInputMethodEvent already includes + // the selection, as the documentation says that "If the widget + // has selected text, the selected text should get removed." + replaceLength -= selectedRange.length; + + // The replacement length of QInputMethodEvent already includes + // the preedit string, as the documentation says that "When doing + // replacement, the area of the preedit string is ignored". + replaceLength -= markedRange.length; + + // What we're left with is any _additional_ replacement. + // Make sure it's valid before passing it on. + replaceLength = qMax(0ll, replaceLength); + + return {replaceFrom, replaceLength}; } @end |