diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt index 38f68d38b..d1067db72 100644 --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -45,12 +45,6 @@ ecm_add_test( TEST_NAME chatbarcachetest ) -ecm_add_test( - chatdocumenthandlertest.cpp - LINK_LIBRARIES neochat Qt::Test - TEST_NAME chatdocumenthandlertest -) - ecm_add_test( timelinemessagemodeltest.cpp LINK_LIBRARIES neochat Qt::Test diff --git a/autotests/chatdocumenthandlertest.cpp b/autotests/chatdocumenthandlertest.cpp deleted file mode 100644 index 7194ec92c..000000000 --- a/autotests/chatdocumenthandlertest.cpp +++ /dev/null @@ -1,36 +0,0 @@ -// SPDX-FileCopyrightText: 2023 James Graham -// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL - -#include -#include - -#include "chatdocumenthandler.h" -#include "neochatconfig.h" - -class ChatDocumentHandlerTest : public QObject -{ - Q_OBJECT - -private: - ChatDocumentHandler emptyHandler; - -private Q_SLOTS: - void initTestCase(); - - void nullComplete(); -}; - -void ChatDocumentHandlerTest::initTestCase() -{ - // HACK: this is to stop KStatusNotifierItem SEGFAULTING on cleanup. - NeoChatConfig::self()->setSystemTray(false); -} - -void ChatDocumentHandlerTest::nullComplete() -{ - QTest::ignoreMessage(QtWarningMsg, "complete called with m_document set to nullptr."); - emptyHandler.complete(0); -} - -QTEST_MAIN(ChatDocumentHandlerTest) -#include "chatdocumenthandlertest.moc" diff --git a/autotests/chatdocumenthelpertest.qml b/autotests/chatdocumenthelpertest.qml index e62d5ffa7..5218841d8 100644 --- a/autotests/chatdocumenthelpertest.qml +++ b/autotests/chatdocumenthelpertest.qml @@ -14,14 +14,13 @@ TestCase { } TextEdit { - id: TextEdit + id: textEdit } function test_empty(): void { compare(documentHandler.type, LibNeoChat.ChatBarType.None); compare(documentHandler.room, null); compare(documentHandler.textItem, null); - compare(documentHandler.completionModel instanceof LibNeoChat.CompletionModel, true); compare(documentHandler.atFirstLine, false); compare(documentHandler.atLastLine, false); compare(documentHandler.bold, false); diff --git a/src/chatbar/ChatBar.qml b/src/chatbar/ChatBar.qml index a36eaf90a..d6fe5ee88 100644 --- a/src/chatbar/ChatBar.qml +++ b/src/chatbar/ChatBar.qml @@ -150,7 +150,6 @@ QQC2.Control { CompletionMenu { id: completionMenu chatDocumentHandler: contentModel.focusedDocumentHandler - connection: root.connection x: 1 y: -height diff --git a/src/chatbar/CompletionMenu.qml b/src/chatbar/CompletionMenu.qml index 539de6ecf..574714eb5 100644 --- a/src/chatbar/CompletionMenu.qml +++ b/src/chatbar/CompletionMenu.qml @@ -16,12 +16,7 @@ import org.kde.neochat QQC2.Popup { id: root - required property NeoChatConnection connection required property var chatDocumentHandler - onChatDocumentHandlerChanged: if (chatDocumentHandler) { - chatDocumentHandler.completionModel.roomListModel = RoomManager.roomListModel; - chatDocumentHandler.completionModel.userListModel = RoomManager.userListModel; - } visible: completions.count > 0 @@ -38,7 +33,7 @@ QQC2.Popup { } function complete() { - root.chatDocumentHandler.complete(completions.currentIndex); + root.chatDocumentHandler.insertCompletion(completions.currentItem.replacedText, completions.currentItem.hRef) } leftPadding: 0 @@ -65,7 +60,11 @@ QQC2.Popup { ListView { id: completions - model: root.chatDocumentHandler.completionModel + model: CompletionModel { + textItem: root.chatDocumentHandler.textItem + roomListModel: RoomManager.roomListModel + userListModel: RoomManager.userListModel + } currentIndex: 0 keyNavigationWraps: true highlightMoveDuration: 100 @@ -77,6 +76,8 @@ QQC2.Popup { required property string displayName required property string subtitle required property string iconName + required property string replacedText + required property url hRef text: displayName @@ -96,7 +97,7 @@ QQC2.Popup { subtitleItem.textFormat: Text.PlainText } } - onClicked: root.chatDocumentHandler.complete(completionDelegate.index) + onClicked: root.chatDocumentHandler.insertCompletion(replacedText, hRef) } } } diff --git a/src/libneochat/CMakeLists.txt b/src/libneochat/CMakeLists.txt index 3275f7073..cd37658f3 100644 --- a/src/libneochat/CMakeLists.txt +++ b/src/libneochat/CMakeLists.txt @@ -21,6 +21,7 @@ target_sources(LibNeoChat PRIVATE neochatdatetime.cpp nestedlisthelper_p.h nestedlisthelper.cpp + qmltextitemwrapper.cpp roomlastmessageprovider.cpp spacehierarchycache.cpp texthandler.cpp diff --git a/src/libneochat/chatdocumenthandler.cpp b/src/libneochat/chatdocumenthandler.cpp index 18fc2b76b..2a659b193 100644 --- a/src/libneochat/chatdocumenthandler.cpp +++ b/src/libneochat/chatdocumenthandler.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -84,6 +85,7 @@ public: setFormat(error.first, error.second.size(), errorFormat); } } + auto handler = dynamic_cast(parent()); auto room = handler->room(); if (!room) { @@ -109,6 +111,7 @@ public: mention.cursor.setPosition(mention.cursor.anchor() + mention.text.size(), QTextCursor::KeepAnchor); } + qWarning() << mention.cursor.selectedText() << mention.text; if (mention.cursor.selectedText() != mention.text) { return true; } @@ -128,29 +131,12 @@ private: ChatDocumentHandler::ChatDocumentHandler(QObject *parent) : QObject(parent) + , m_markdownHelper(new ChatMarkdownHelper(this)) , m_highlighter(new SyntaxHighlighter(this)) - , m_completionModel(new CompletionModel(this)) { - m_markdownHelper = new ChatMarkdownHelper(this); connect(this, &ChatDocumentHandler::formatChanged, m_markdownHelper, &ChatMarkdownHelper::handleExternalFormatChange); } -int ChatDocumentHandler::completionStartIndex() const -{ - const qsizetype cursor = cursorPosition(); - const auto &text = getText(); - - auto start = std::min(cursor, text.size()) - 1; - while (start > -1) { - if (text.at(start) == QLatin1Char(' ')) { - start++; - break; - } - start--; - } - return start; -} - ChatBarType::Type ChatDocumentHandler::type() const { return m_type; @@ -177,7 +163,6 @@ void ChatDocumentHandler::setRoom(NeoChatRoom *room) } m_room = room; - m_completionModel->setRoom(m_room); Q_EMIT roomChanged(); } @@ -200,8 +185,8 @@ void ChatDocumentHandler::setTextItem(QQuickItem *textItem) } m_textItem = textItem; - m_highlighter->setDocument(document()); + if (m_textItem) { connect(m_textItem, SIGNAL(cursorPositionChanged()), this, SLOT(updateCursor())); if (document()) { @@ -338,9 +323,6 @@ int ChatDocumentHandler::cursorPosition() const void ChatDocumentHandler::updateCursor() { - int start = completionStartIndex(); - m_completionModel->setText(getText().mid(start, cursorPosition() - start), getText().mid(start)); - Q_EMIT atFirstLineChanged(); Q_EMIT atLastLineChanged(); } @@ -559,71 +541,6 @@ void ChatDocumentHandler::insertFragment(const QTextDocumentFragment fragment, I } } -void ChatDocumentHandler::complete(int index) -{ - if (document() == nullptr) { - qCWarning(ChatDocumentHandling) << "complete called with m_document set to nullptr."; - return; - } - if (m_completionModel->autoCompletionType() == CompletionModel::None) { - qCWarning(ChatDocumentHandling) << "complete called with m_completionModel->autoCompletionType() == CompletionModel::None."; - return; - } - - // Ensure we only search for the beginning of the current completion identifier - const auto fromIndex = qMax(completionStartIndex(), 0); - - if (m_completionModel->autoCompletionType() == CompletionModel::User) { - auto name = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::DisplayNameRole).toString(); - auto id = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::SubtitleRole).toString(); - auto text = getText(); - auto at = text.indexOf(QLatin1Char('@'), fromIndex); - QTextCursor cursor(document()); - cursor.setPosition(at); - cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor); - cursor.insertText(name + u" "_s); - cursor.setPosition(at); - cursor.setPosition(cursor.position() + name.size(), QTextCursor::KeepAnchor); - cursor.setKeepPositionOnInsert(true); - pushMention({cursor, name, 0, 0, id}); - m_highlighter->rehighlight(); - } else if (m_completionModel->autoCompletionType() == CompletionModel::Command) { - auto command = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::ReplacedTextRole).toString(); - auto text = getText(); - auto at = text.indexOf(QLatin1Char('/'), fromIndex); - QTextCursor cursor(document()); - cursor.setPosition(at); - cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor); - cursor.insertText(u"/%1 "_s.arg(command)); - } else if (m_completionModel->autoCompletionType() == CompletionModel::Room) { - auto alias = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::SubtitleRole).toString(); - auto text = getText(); - auto at = text.indexOf(QLatin1Char('#'), fromIndex); - QTextCursor cursor(document()); - cursor.setPosition(at); - cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor); - cursor.insertText(alias + u" "_s); - cursor.setPosition(at); - cursor.setPosition(cursor.position() + alias.size(), QTextCursor::KeepAnchor); - cursor.setKeepPositionOnInsert(true); - pushMention({cursor, alias, 0, 0, alias}); - m_highlighter->rehighlight(); - } else if (m_completionModel->autoCompletionType() == CompletionModel::Emoji) { - auto shortcode = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::ReplacedTextRole).toString(); - auto text = getText(); - auto at = text.indexOf(QLatin1Char(':'), fromIndex); - QTextCursor cursor(document()); - cursor.setPosition(at); - cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor); - cursor.insertText(shortcode); - } -} - -CompletionModel *ChatDocumentHandler::completionModel() const -{ - return m_completionModel; -} - QString ChatDocumentHandler::getText() const { if (!document()) { @@ -861,6 +778,39 @@ void ChatDocumentHandler::insertTable(int rows, int columns) return; } +void ChatDocumentHandler::insertCompletion(const QString &text, const QUrl &link) +{ + QTextCursor cursor = textCursor(); + if (cursor.isNull()) { + return; + } + + cursor.beginEditBlock(); + while (!cursor.selectedText().startsWith(u' ') && !cursor.atBlockStart()) { + cursor.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor); + } + if (cursor.selectedText().startsWith(u' ')) { + cursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor); + } + cursor.removeSelectedText(); + + const int start = cursor.position(); + const auto insertString = u"%1 %2"_s.arg(text, link.isEmpty() ? QString() : u" "_s); + cursor.insertText(insertString); + cursor.setPosition(start); + cursor.setPosition(start + text.size(), QTextCursor::KeepAnchor); + cursor.setKeepPositionOnInsert(true); + cursor.endEditBlock(); + if (!link.isEmpty()) { + pushMention({ + .cursor = cursor, + .text = text, + .id = link.toString(), + }); + } + m_highlighter->rehighlight(); +} + void ChatDocumentHandler::updateLink(const QString &linkUrl, const QString &linkText) { auto cursor = textCursor(); diff --git a/src/libneochat/chatdocumenthandler.h b/src/libneochat/chatdocumenthandler.h index 6c45f0ec9..3d82de288 100644 --- a/src/libneochat/chatdocumenthandler.h +++ b/src/libneochat/chatdocumenthandler.h @@ -14,7 +14,6 @@ #include "chatmarkdownhelper.h" #include "enums/chatbartype.h" #include "enums/richformat.h" -#include "models/completionmodel.h" #include "neochatroom.h" #include "nestedlisthelper_p.h" @@ -85,14 +84,6 @@ class ChatDocumentHandler : public QObject */ Q_PROPERTY(QQuickItem *textItem READ textItem WRITE setTextItem NOTIFY textItemChanged) - /** - * @brief The current CompletionModel. - * - * This is typically provided to a qml component to visualise the current - * completion results. - */ - Q_PROPERTY(CompletionModel *completionModel READ completionModel CONSTANT) - /** * @brief Whether the cursor is currently on the first line. */ @@ -155,10 +146,6 @@ public: QTextDocumentFragment takeFirstBlock(); void fillFragments(bool &hasBefore, QTextDocumentFragment &midFragment, std::optional &afterFragment); - Q_INVOKABLE void complete(int index); - - CompletionModel *completionModel() const; - /** * @brief Update the mentions in @p document when editing a message. */ @@ -194,6 +181,7 @@ public: Q_INVOKABLE void updateLink(const QString &linkUrl, const QString &linkText); Q_INVOKABLE void insertImage(const QUrl &imagePath); Q_INVOKABLE void insertTable(int rows, int columns); + Q_INVOKABLE void insertCompletion(const QString &text, const QUrl &link); Q_INVOKABLE void dumpHtml(); Q_INVOKABLE QString htmlText() const; @@ -248,9 +236,6 @@ private: SyntaxHighlighter *m_highlighter = nullptr; - int completionStartIndex() const; - - CompletionModel *m_completionModel = nullptr; QString getText() const; void pushMention(const Mention mention) const; diff --git a/src/libneochat/models/completionmodel.cpp b/src/libneochat/models/completionmodel.cpp index bd254f8f7..63c6bed5f 100644 --- a/src/libneochat/models/completionmodel.cpp +++ b/src/libneochat/models/completionmodel.cpp @@ -2,35 +2,55 @@ // SPDX-License-Identifier: LGPL-2.0-or-later #include "completionmodel.h" + #include +#include #include "completionproxymodel.h" #include "models/actionsmodel.h" #include "models/customemojimodel.h" #include "models/emojimodel.h" -#include "neochatroom.h" +#include "qmltextitemwrapper.h" #include "userlistmodel.h" CompletionModel::CompletionModel(QObject *parent) : QAbstractListModel(parent) + , m_textItem(new QmlTextItemWrapper(this)) , m_filterModel(new CompletionProxyModel(this)) , m_emojiModel(new QConcatenateTablesProxyModel(this)) { - connect(this, &CompletionModel::textChanged, this, &CompletionModel::updateCompletion); + connect(m_textItem, &QmlTextItemWrapper::textItemChanged, this, &CompletionModel::textItemChanged); + connect(m_textItem, &QmlTextItemWrapper::textDocumentCursorPositionChanged, this, &CompletionModel::updateTextStart); + connect(m_textItem, &QmlTextItemWrapper::textDocumentContentsChanged, this, &CompletionModel::updateCompletion); + m_emojiModel->addSourceModel(&CustomEmojiModel::instance()); m_emojiModel->addSourceModel(&EmojiModel::instance()); } -QString CompletionModel::text() const +QQuickItem *CompletionModel::textItem() const { - return m_text; + return m_textItem->textItem(); } -void CompletionModel::setText(const QString &text, const QString &fullText) +void CompletionModel::setTextItem(QQuickItem *textItem) { - m_text = text; - m_fullText = fullText; - Q_EMIT textChanged(); + m_textItem->setTextItem(textItem); +} + +void CompletionModel::updateTextStart() +{ + auto cursor = m_textItem->textCursor(); + if (cursor.isNull()) { + return; + } + + cursor.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor); + while (cursor.selectedText() != u' ' && !cursor.atBlockStart()) { + cursor.movePosition(QTextCursor::PreviousCharacter); + cursor.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor); + } + m_textStart = cursor.position() == 0 && cursor.selectedText() != u' ' ? 0 : cursor.position() + 1; + updateCompletion(); } int CompletionModel::rowCount(const QModelIndex &parent) const @@ -58,6 +78,12 @@ QVariant CompletionModel::data(const QModelIndex &index, int role) const if (role == IconNameRole) { return m_filterModel->data(filterIndex, UserListModel::AvatarRole); } + if (role == ReplacedTextRole) { + return m_filterModel->data(filterIndex, UserListModel::DisplayNameRole); + } + if (role == HRefRole) { + return u"https://matrix.to/#/%1"_s.arg(m_filterModel->data(filterIndex, UserListModel::UserIdRole).toString()); + } } if (m_autoCompletionType == Command) { @@ -85,6 +111,12 @@ QVariant CompletionModel::data(const QModelIndex &index, int role) const if (role == IconNameRole) { return m_filterModel->data(filterIndex, RoomListModel::AvatarRole).toString(); } + if (role == ReplacedTextRole) { + return m_filterModel->data(filterIndex, RoomListModel::CanonicalAliasRole); + } + if (role == HRefRole) { + return u"https://matrix.to/#/%1"_s.arg(m_filterModel->data(filterIndex, RoomListModel::CanonicalAliasRole).toString()); + } } if (m_autoCompletionType == Emoji) { if (role == DisplayNameRole) { @@ -111,44 +143,57 @@ QHash CompletionModel::roleNames() const {SubtitleRole, "subtitle"}, {IconNameRole, "iconName"}, {ReplacedTextRole, "replacedText"}, + {HRefRole, "hRef"}, }; } void CompletionModel::updateCompletion() { - if (text().startsWith(QLatin1Char('@'))) { + auto cursor = m_textItem->textCursor(); + if (cursor.isNull()) { + return; + } + cursor.setPosition(m_textStart); + while (!cursor.selectedText().endsWith(u' ') && !cursor.atBlockEnd()) { + cursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor); + } + const auto text = cursor.selectedText().trimmed(); + cursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor); + const auto fullText = cursor.selectedText(); + + if (text.startsWith(QLatin1Char('@'))) { m_filterModel->setSourceModel(m_userListModel); m_filterModel->setFilterRole(UserListModel::UserIdRole); m_filterModel->setSecondaryFilterRole(UserListModel::DisplayNameRole); - m_filterModel->setFullText(m_fullText); - m_filterModel->setFilterText(m_text); + m_filterModel->setFullText(fullText); + m_filterModel->setFilterText(text); m_autoCompletionType = User; m_filterModel->invalidate(); - } else if (text().startsWith(QLatin1Char('/'))) { + } else if (text.startsWith(QLatin1Char('/'))) { m_filterModel->setSourceModel(&ActionsModel::instance()); m_filterModel->setFilterRole(ActionsModel::Prefix); m_filterModel->setSecondaryFilterRole(-1); - m_filterModel->setFullText(m_fullText); - m_filterModel->setFilterText(m_text.mid(1)); + m_filterModel->setFullText(fullText); + m_filterModel->setFilterText(text.mid(1)); m_autoCompletionType = Command; m_filterModel->invalidate(); - } else if (text().startsWith(QLatin1Char('#'))) { + } else if (text.startsWith(QLatin1Char('#'))) { m_autoCompletionType = Room; m_filterModel->setSourceModel(m_roomListModel); m_filterModel->setFilterRole(RoomListModel::CanonicalAliasRole); m_filterModel->setSecondaryFilterRole(RoomListModel::DisplayNameRole); - m_filterModel->setFullText(m_fullText); - m_filterModel->setFilterText(m_text); + m_filterModel->setFullText(fullText); + m_filterModel->setFilterText(text); m_filterModel->invalidate(); - } else if (text().startsWith(QLatin1Char(':')) && text().size() > 1 && !text()[1].isUpper() - && (m_fullText.indexOf(QLatin1Char(':'), 1) == -1 - || (m_fullText.indexOf(QLatin1Char(' ')) != -1 && m_fullText.indexOf(QLatin1Char(':'), 1) > m_fullText.indexOf(QLatin1Char(' '), 1)))) { + } else if (text.startsWith(QLatin1Char(':')) && text.size() > 1 && !text[1].isUpper() + && (fullText.indexOf(QLatin1Char(':'), 1) == -1 + || (fullText.indexOf(QLatin1Char(' ')) != -1 && fullText.indexOf(QLatin1Char(':'), 1) > fullText.indexOf(QLatin1Char(' '), 1)))) { m_filterModel->setSourceModel(m_emojiModel); m_autoCompletionType = Emoji; m_filterModel->setFilterRole(CustomEmojiModel::Name); m_filterModel->setSecondaryFilterRole(EmojiModel::DescriptionRole); - m_filterModel->setFullText(m_fullText); - m_filterModel->setFilterText(m_text); + m_filterModel->setFullText(fullText); + m_filterModel->setFilterText(text); m_filterModel->invalidate(); } else { m_autoCompletionType = None; @@ -157,17 +202,6 @@ void CompletionModel::updateCompletion() endResetModel(); } -NeoChatRoom *CompletionModel::room() const -{ - return m_room; -} - -void CompletionModel::setRoom(NeoChatRoom *room) -{ - m_room = room; - Q_EMIT roomChanged(); -} - CompletionModel::AutoCompletionType CompletionModel::autoCompletionType() const { return m_autoCompletionType; diff --git a/src/libneochat/models/completionmodel.h b/src/libneochat/models/completionmodel.h index 32092330f..84f2c9933 100644 --- a/src/libneochat/models/completionmodel.h +++ b/src/libneochat/models/completionmodel.h @@ -5,13 +5,14 @@ #include #include +#include #include #include "roomlistmodel.h" class CompletionProxyModel; class UserListModel; -class NeoChatRoom; +class QmlTextItemWrapper; class RoomListModel; /** @@ -28,14 +29,9 @@ class CompletionModel : public QAbstractListModel QML_ELEMENT /** - * @brief The current text to search for completions. + * @brief The QML text Item that completions are being provided for. */ - Q_PROPERTY(QString text READ text NOTIFY textChanged) - - /** - * @brief The current room that the model is getting completions for. - */ - Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged) + Q_PROPERTY(QQuickItem *textItem READ textItem WRITE setTextItem NOTIFY textItemChanged) /** * @brief The current type of completion being done on the entered text. @@ -72,14 +68,18 @@ public: */ enum Roles { DisplayNameRole = Qt::DisplayRole, /**< The main text to show. */ - SubtitleRole, /**< The subtitle text to show. */ + SubtitleRole = Qt::UserRole, /**< The subtitle text to show. */ IconNameRole, /**< The icon to show. */ ReplacedTextRole, /**< The text to replace the input text with for the completion. */ + HRefRole, /**< The hyperlink if applicable for the completion. */ }; Q_ENUM(Roles) explicit CompletionModel(QObject *parent = nullptr); + QQuickItem *textItem() const; + void setTextItem(QQuickItem *textItem); + /** * @brief Get the given role value at the given index. * @@ -101,12 +101,6 @@ public: */ QHash roleNames() const override; - QString text() const; - void setText(const QString &text, const QString &fullText); - - NeoChatRoom *room() const; - void setRoom(NeoChatRoom *room); - RoomListModel *roomListModel() const; void setRoomListModel(RoomListModel *roomListModel); @@ -117,17 +111,20 @@ public: void setAutoCompletionType(AutoCompletionType autoCompletionType); Q_SIGNALS: - void textChanged(); + void textItemChanged(); + void roomChanged(); void autoCompletionTypeChanged(); void roomListModelChanged(); void userListModelChanged(); private: - QString m_text; - QString m_fullText; + QPointer m_textItem; + + int m_textStart = 0; + void updateTextStart(); + CompletionProxyModel *m_filterModel; - QPointer m_room; AutoCompletionType m_autoCompletionType = None; void updateCompletion(); diff --git a/src/libneochat/qmltextitemwrapper.cpp b/src/libneochat/qmltextitemwrapper.cpp new file mode 100644 index 000000000..0caea0a5c --- /dev/null +++ b/src/libneochat/qmltextitemwrapper.cpp @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: 2025 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#include "qmltextitemwrapper.h" + +#include +#include + +QmlTextItemWrapper::QmlTextItemWrapper(QObject *parent) + : QObject(parent) +{ +} + +QQuickItem *QmlTextItemWrapper::textItem() const +{ + return m_textItem; +} + +void QmlTextItemWrapper::setTextItem(QQuickItem *textItem) +{ + if (textItem == m_textItem) { + return; + } + + if (m_textItem) { + m_textItem->disconnect(this); + if (const auto textDoc = document()) { + textDoc->disconnect(this); + } + } + + m_textItem = textItem; + + if (m_textItem) { + connect(m_textItem, SIGNAL(cursorPositionChanged()), this, SLOT(textDocCursorChanged())); + if (document()) { + connect(document(), &QTextDocument::contentsChanged, this, &QmlTextItemWrapper::textDocumentContentsChanged); + } + } + + Q_EMIT textItemChanged(); +} + +QTextDocument *QmlTextItemWrapper::document() const +{ + if (!m_textItem) { + return nullptr; + } + const auto quickDocument = qvariant_cast(textItem()->property("textDocument")); + return quickDocument ? quickDocument->textDocument() : nullptr; +} + +int QmlTextItemWrapper::cursorPosition() const +{ + if (!m_textItem) { + return -1; + } + return m_textItem->property("cursorPosition").toInt(); +} + +int QmlTextItemWrapper::selectionStart() const +{ + if (!m_textItem) { + return -1; + } + return m_textItem->property("selectionStart").toInt(); +} + +int QmlTextItemWrapper::selectionEnd() const +{ + if (!m_textItem) { + return -1; + } + return m_textItem->property("selectionEnd").toInt(); +} + +QTextCursor QmlTextItemWrapper::textCursor() const +{ + if (!document()) { + return QTextCursor(); + } + + QTextCursor cursor = QTextCursor(document()); + if (selectionStart() != selectionEnd()) { + cursor.setPosition(selectionStart()); + cursor.setPosition(selectionEnd(), QTextCursor::KeepAnchor); + } else { + cursor.setPosition(cursorPosition()); + } + return cursor; +} + +void QmlTextItemWrapper::textDocCursorChanged() +{ + Q_EMIT textDocumentCursorPositionChanged(); +} + +#include "moc_qmltextitemwrapper.cpp" diff --git a/src/libneochat/qmltextitemwrapper.h b/src/libneochat/qmltextitemwrapper.h new file mode 100644 index 000000000..b3f1f78fd --- /dev/null +++ b/src/libneochat/qmltextitemwrapper.h @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2025 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#pragma once + +#include +#include + +class QTextDocument; + +/** + * @class QmlTextItemWrapper + * + * A class to wrap around a QQuickItem that is a QML TextEdit (or inherited from it) and provide easy acess to its properties. + * + * This basically exists because Qt does not give us access to the cpp headers of + * most QML items. + * + * @sa QQuickItem, TextEdit + */ +class QmlTextItemWrapper : public QObject +{ + Q_OBJECT + +public: + explicit QmlTextItemWrapper(QObject *parent); + + QQuickItem *textItem() const; + void setTextItem(QQuickItem *textItem); + + QTextDocument *document() const; + + QTextCursor textCursor() const; + +Q_SIGNALS: + void textItemChanged(); + + void textDocumentContentsChanged(); + + void textDocumentCursorPositionChanged(); + +private: + QPointer m_textItem; + + int cursorPosition() const; + int selectionStart() const; + int selectionEnd() const; + +private Q_SLOTS: + void textDocCursorChanged(); +}; diff --git a/src/messagecontent/ChatBarComponent.qml b/src/messagecontent/ChatBarComponent.qml index 90c6281d0..0b3d84521 100644 --- a/src/messagecontent/ChatBarComponent.qml +++ b/src/messagecontent/ChatBarComponent.qml @@ -110,7 +110,7 @@ QQC2.Control { height: implicitHeight y: -height - 5 z: 10 - connection: root.Message.room.connection as NeoChatConnection + chatDocumentHandler: documentHandler margins: 0 Behavior on height {