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 <richard.gustavsen@qt.io>
This commit is contained in:
Tor Arne Vestbø 2024-08-09 11:15:53 +02:00
parent 5c82db79d9
commit 2a9444920b
6 changed files with 189 additions and 46 deletions

View File

@ -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
*/

View File

@ -633,7 +633,8 @@ public:
Cursor,
Language,
Ruby,
Selection
Selection,
MimeData
};
class Attribute {
public:

View File

@ -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"

View File

@ -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);

View File

@ -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)
return NO;
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) << "Reading" << insertedString << "from service pasteboard" << pasteboard.name;
[self insertText:insertedString replacementRange:{NSNotFound, 0}];
return YES;
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;
}
}
- (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

View File

@ -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: