diff options
author | Tor Arne Vestbø <[email protected]> | 2024-08-09 11:15:53 +0200 |
---|---|---|
committer | Tor Arne Vestbø <[email protected]> | 2025-05-19 19:42:30 +0200 |
commit | 2a9444920bcd2f5d1832971bd6275ebdc5515546 (patch) | |
tree | d0af17d1b64206b00c6ffa79dcebf7660d87a9cf | |
parent | 5c82db79d98d456a9299314fab2e7576db227fd0 (diff) |
macOS: Make NSServicesMenuRequestor implementation rich-text aware
The protocol is used by services that interact with content in the
application on behalf of the user.
So far we have only been able to deal with plain text content,
which resulted in wiping any formatting if the user tried to
use a service to rewrite text in a rich text document.
We now support rich text, by teaching our IM protocol how to
deal with rich text for both reporting of the current text
selection, as well as text insertion (commit).
Unfortunately this doesn't help us for Writing Tools, as in
15.2 it no longer uses the NSServicesMenuRequestor protocol
for insertion if we also implement NSTextInputClient. As
a result we get insertions via insertText:replacementRange:,
which is not prepared for rich text yet.
[ChangeLog][macOS] Text services via the Services menu now
support rich text extraction and insertion.
Task-number: QTBUG-126238
Change-Id: I3d2933d766af8fe29e4f17636f703a257bf389fd
Reviewed-by: Richard Moe Gustavsen <[email protected]>
-rw-r--r-- | src/gui/kernel/qevent.cpp | 5 | ||||
-rw-r--r-- | src/gui/kernel/qevent.h | 3 | ||||
-rw-r--r-- | src/gui/text/qinputcontrol.cpp | 35 | ||||
-rw-r--r-- | src/gui/text/qinputcontrol_p.h | 5 | ||||
-rw-r--r-- | src/plugins/platforms/cocoa/qnsview_complextext.mm | 161 | ||||
-rw-r--r-- | src/widgets/widgets/qwidgettextcontrol.cpp | 24 |
6 files changed, 188 insertions, 45 deletions
diff --git a/src/gui/kernel/qevent.cpp b/src/gui/kernel/qevent.cpp index 762522daa0f..af3ad64689d 100644 --- a/src/gui/kernel/qevent.cpp +++ b/src/gui/kernel/qevent.cpp @@ -2250,6 +2250,11 @@ QContextMenuEvent::QContextMenuEvent(Reason reason, const QPoint &pos) variable can be used to set a selection starting from that point. The value is unused. + \value MimeData + If set, the variant contains a QMimeData object representing the + committed text. The commitString() still provides the plain text + representation of the committed text. + \sa Attribute */ diff --git a/src/gui/kernel/qevent.h b/src/gui/kernel/qevent.h index 2374e527e3e..2aa7de1e245 100644 --- a/src/gui/kernel/qevent.h +++ b/src/gui/kernel/qevent.h @@ -633,7 +633,8 @@ public: Cursor, Language, Ruby, - Selection + Selection, + MimeData }; class Attribute { public: diff --git a/src/gui/text/qinputcontrol.cpp b/src/gui/text/qinputcontrol.cpp index 4edd46ff0e7..d5f5290527b 100644 --- a/src/gui/text/qinputcontrol.cpp +++ b/src/gui/text/qinputcontrol.cpp @@ -2,6 +2,8 @@ // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only #include "qinputcontrol_p.h" + +#include <QtCore/qmimedata.h> #include <QtGui/qevent.h> QT_BEGIN_NAMESPACE @@ -103,6 +105,39 @@ bool QInputControl::isCommonTextEditShortcut(const QKeyEvent *ke) return false; } +/*! + \internal + + Creates a wrapper for returning QMimeData in response to + Qt::ImCurrentSelection, while being backwards compatible + with clients who only read the plain text string. +*/ +QVariant QInputControl::selectionWrapper(QMimeData *mimeData) +{ + struct MimeDataSelection + { + QMimeData *mimeData = nullptr; + operator QMimeData*() const { return mimeData; } + operator QString() const { return mimeData->text(); } + }; + + static bool registeredConversions = []{ + return QMetaType::registerConverter<MimeDataSelection, QMimeData*>() + && QMetaType::registerConverter<MimeDataSelection, QString>(); + }(); + Q_ASSERT(registeredConversions); + return QVariant::fromValue(MimeDataSelection{mimeData}); +} + +QMimeData *QInputControl::mimeDataForInputEvent(QInputMethodEvent *event) +{ + const auto &attributes = event->attributes(); + auto mimeDataAttr = std::find_if(attributes.begin(), attributes.end(), + [](auto a) { return a.type == QInputMethodEvent::MimeData; }); + return mimeDataAttr != event->attributes().end() ? + mimeDataAttr->value.value<QMimeData*>() : nullptr; +} + QT_END_NAMESPACE #include "moc_qinputcontrol_p.cpp" diff --git a/src/gui/text/qinputcontrol_p.h b/src/gui/text/qinputcontrol_p.h index fec73e1987b..bf7ed013874 100644 --- a/src/gui/text/qinputcontrol_p.h +++ b/src/gui/text/qinputcontrol_p.h @@ -22,6 +22,8 @@ QT_BEGIN_NAMESPACE class QKeyEvent; +class QMimeData; +class QInputMethodEvent; class Q_GUI_EXPORT QInputControl : public QObject { Q_OBJECT @@ -36,6 +38,9 @@ public: bool isAcceptableInput(const QKeyEvent *event) const; static bool isCommonTextEditShortcut(const QKeyEvent *ke); + static QVariant selectionWrapper(QMimeData *mimeData); + static QMimeData *mimeDataForInputEvent(QInputMethodEvent *event); + protected: explicit QInputControl(Type type, QObjectPrivate &dd, QObject *parent = nullptr); diff --git a/src/plugins/platforms/cocoa/qnsview_complextext.mm b/src/plugins/platforms/cocoa/qnsview_complextext.mm index 6060e5219df..2c53647a7f3 100644 --- a/src/plugins/platforms/cocoa/qnsview_complextext.mm +++ b/src/plugins/platforms/cocoa/qnsview_complextext.mm @@ -652,60 +652,149 @@ @implementation QNSView (ServicesMenu) -// Support for reading and writing from service menu pasteboards, which is also -// how the writing tools interact with custom NSView. Note that we only support -// plain text, which means that a rich text selection will lose all its styling -// when fed through a service that changes the text. To support rich text we -// need IM plumbing that operates on QMimeData. +// Support for reading and writing from service menu pasteboards. If the text +// input client supports returning the selection as a QMimeData we can convert +// that to rich text. Otherwise we fall back to plain text, which means that we +// lose any styling the selection might have when fed through a service that +// changes the text. - (id)validRequestorForSendType:(NSPasteboardType)sendType returnType:(NSPasteboardType)returnType { - bool canWriteToPasteboard = [&]{ - if (![sendType isEqualToString:NSPasteboardTypeString]) - return false; - if (auto queryResult = queryInputMethod(self.focusObject, Qt::ImCurrentSelection)) { - auto selectedText = queryResult.value(Qt::ImCurrentSelection).toString(); - if (!selectedText.isEmpty()) - return true; + if (auto queryResult = queryInputMethod(self.focusObject, Qt::ImReadOnly | Qt::ImCurrentSelection)) { + bool canWriteToPasteboard = false; + bool canReadFromPastboard = false; + + auto currentSelection = queryResult.value(Qt::ImCurrentSelection); + if (auto *mimeData = currentSelection.value<QMimeData*>()) { + // If the client reports the selection as mime-data we assume + // it can also insert mime-data via QInputMethodEvent::MimeData + auto scope = QUtiMimeConverter::HandlerScopeFlag::Clipboard; + auto availableConverters = QMacMimeRegistry::all(scope); + auto sendUti = [self utiForPasteboardType:sendType]; + auto returnUti = [self utiForPasteboardType:returnType]; + const auto mimeFormats = mimeData->formats(); + for (const auto *c : availableConverters) { + if (mimeFormats.contains(c->mimeForUti(sendUti))) + canWriteToPasteboard = true; + if (mimeFormats.contains(c->mimeForUti(returnUti))) + canReadFromPastboard = true; + if (canWriteToPasteboard && canReadFromPastboard) + break; // No need to continue looking + } + } else { + canWriteToPasteboard = [sendType isEqualToString:NSPasteboardTypeString] + && !currentSelection.toString().isEmpty(); + canReadFromPastboard = [returnType isEqualToString:NSPasteboardTypeString] + && !queryResult.value(Qt::ImReadOnly).toBool(); } - return false; - }(); - - bool canReadFromPastboard = [returnType isEqualToString:NSPasteboardTypeString]; - if ((sendType && !canWriteToPasteboard) || (returnType && !canReadFromPastboard)) { - return [super validRequestorForSendType:sendType returnType:returnType]; - } else { - qCDebug(lcQpaServices) << "Accepting service interaction for send" << sendType << "and receive" << returnType; - return self; + if (!((sendType && !canWriteToPasteboard) || (returnType && !canReadFromPastboard))) { + qCDebug(lcQpaServices) << "Accepting service interaction for send" << sendType << "and receive" << returnType; + return self; + } } + + return [super validRequestorForSendType:sendType returnType:returnType]; } - (BOOL)writeSelectionToPasteboard:(NSPasteboard *)pasteboard types:(NSArray<NSPasteboardType> *)types { - if ([types containsObject:NSPasteboardTypeString] - // Check for the deprecated NSStringPboardType as well, as even if we - // claim to only support NSPasteboardTypeString, we get callbacks for - // the deprecated type. - || QT_IGNORE_DEPRECATIONS([types containsObject:NSStringPboardType])) { - if (auto queryResult = queryInputMethod(self.focusObject, Qt::ImCurrentSelection)) { - auto selectedText = queryResult.value(Qt::ImCurrentSelection).toString(); - qCDebug(lcQpaServices) << "Writing" << selectedText << "to service pasteboard" << pasteboard.name; - return [pasteboard writeObjects:@[ selectedText.toNSString() ]]; + bool didWrite = false; + + if (auto queryResult = queryInputMethod(self.focusObject, Qt::ImCurrentSelection)) { + auto currentSelection = queryResult.value(Qt::ImCurrentSelection); + if (auto *mimeData = currentSelection.value<QMimeData*>()) { + auto mimeFormats = mimeData->formats(); + auto scope = QUtiMimeConverter::HandlerScopeFlag::Clipboard; + auto availableConverters = QMacMimeRegistry::all(scope); + for (NSPasteboardType type in types) { + auto uti = [self utiForPasteboardType:type]; + if (uti.isEmpty()) { + qCWarning(lcQpaServices) << "Did not find UTI for type" << type; + continue; + } + for (const auto *converter : availableConverters) { + auto mime = converter->mimeForUti(uti); + if (mimeFormats.contains(mime)) { + auto utiDataList = converter->convertFromMime(mime, + mimeData->data(mime), uti); + if (utiDataList.isEmpty()) + continue; + auto utiData = utiDataList.first(); + qCDebug(lcQpaServices) << "Writing" << utiData << "to service pasteboard" + << "with UTI" << uti << "for type" << type << "based on mime" << mime; + didWrite |= [pasteboard setData:utiData.toNSData() forType:type]; + break; + } + } + } + } + + // Try plain text fallback if we didn't have QMimeData, or didn't write anything + if (!didWrite && ([types containsObject:NSPasteboardTypeString] + || QT_IGNORE_DEPRECATIONS([types containsObject:NSStringPboardType]))) { + auto selectedText = currentSelection.toString(); + qCDebug(lcQpaServices) << "Writing" << selectedText << "to service pasteboard" + << "as pain text" << "for type" << NSPasteboardTypeString; + didWrite |= [pasteboard writeObjects:@[ selectedText.toNSString() ]]; } } - return NO; + + return didWrite; } - (BOOL)readSelectionFromPasteboard:(NSPasteboard *)pasteboard { - NSString *insertedString = [pasteboard stringForType:NSPasteboardTypeString]; - if (!insertedString) + if (queryInputMethod(self.focusObject)) { + auto scope = QUtiMimeConverter::HandlerScopeFlag::Clipboard; + QMacPasteboard macPasteboard(CFStringRef(pasteboard.name), scope); + auto *mimeData = macPasteboard.mimeData(); + if (mimeData->formats().isEmpty()) { + qCWarning(lcQpaServices) << "Failed to resolve mime data from" << pasteboard.types; + return NO; + } + + qCDebug(lcQpaServices) << "Replacing selected range" << [self selectedRange] + << "with mime data" << [&]() { + QMap<QString, QByteArray> formatMap; + for (const auto &format : mimeData->formats()) + formatMap.insert(format, mimeData->data(format)); + return formatMap; + }() << "from service pasteboard" << pasteboard.name; + + QList<QInputMethodEvent::Attribute> attributes; + attributes << QInputMethodEvent::Attribute( + QInputMethodEvent::MimeData, + 0, 0, QVariant::fromValue(mimeData)); + + QInputMethodEvent inputMethodEvent(QString(), attributes); + // Pass the plain text data as the commit string, for clients + // that don't know how to handle the new MimeData attribute. + // This also ensures that we clear the existing selected text. + inputMethodEvent.setCommitString(mimeData->text()); + QCoreApplication::sendEvent(self.focusObject, &inputMethodEvent); + return YES; + } else { return NO; + } +} - qCDebug(lcQpaServices) << "Reading" << insertedString << "from service pasteboard" << pasteboard.name; - [self insertText:insertedString replacementRange:{NSNotFound, 0}]; - return YES; +- (QString)utiForPasteboardType:(NSPasteboardType)pasteboardType +{ + if (!pasteboardType) + return QString(); + + UTType *uttype = [UTType typeWithIdentifier:pasteboardType]; + if (!uttype) { + // Although NSPasteboard types are declared as obsolete + // we still get callbacks for these types. As these types + // are not UTIs, we need to resolve the underlying UTI + // ourselves. + uttype = [UTType typeWithTag:pasteboardType + tagClass:QT_IGNORE_DEPRECATIONS((NSString*)kUTTagClassNSPboardType) + conformingToType:nil]; + } + return QString::fromNSString(uttype.identifier); } @end diff --git a/src/widgets/widgets/qwidgettextcontrol.cpp b/src/widgets/widgets/qwidgettextcontrol.cpp index 710540c6f29..ebe99b90bb9 100644 --- a/src/widgets/widgets/qwidgettextcontrol.cpp +++ b/src/widgets/widgets/qwidgettextcontrol.cpp @@ -2062,12 +2062,17 @@ void QWidgetTextControlPrivate::inputMethodEvent(QInputMethodEvent *e) // insert commit string if (!e->commitString().isEmpty() || e->replacementLength()) { - if (e->commitString().endsWith(QChar::LineFeed)) - block = cursor.block(); // Remember the block where the preedit text is - QTextCursor c = cursor; - c.setPosition(c.position() + e->replacementStart()); - c.setPosition(c.position() + e->replacementLength(), QTextCursor::KeepAnchor); - c.insertText(e->commitString()); + auto *mimeData = QInputControl::mimeDataForInputEvent(e); + if (mimeData && q->canInsertFromMimeData(mimeData)) { + q->insertFromMimeData(mimeData); + } else { + if (e->commitString().endsWith(QChar::LineFeed)) + block = cursor.block(); // Remember the block where the preedit text is + QTextCursor c = cursor; + c.setPosition(c.position() + e->replacementStart()); + c.setPosition(c.position() + e->replacementLength(), QTextCursor::KeepAnchor); + c.insertText(e->commitString()); + } } for (int i = 0; i < e->attributes().size(); ++i) { @@ -2181,8 +2186,11 @@ QVariant QWidgetTextControl::inputMethodQuery(Qt::InputMethodQuery property, QVa return QVariant(d->cursor.position() - block.position()); } case Qt::ImSurroundingText: return QVariant(block.text()); - case Qt::ImCurrentSelection: - return QVariant(d->cursor.selectedText()); + case Qt::ImCurrentSelection: { + QMimeData *mimeData = createMimeDataFromSelection(); + mimeData->deleteLater(); + return QInputControl::selectionWrapper(mimeData); + } case Qt::ImMaximumTextLength: return QVariant(); // No limit. case Qt::ImAnchorPosition: |