Spearate completion from ChatDocumentHandler

This commit is contained in:
James Graham
2025-12-23 16:22:09 +00:00
parent 02bed79265
commit 416d85af3b
13 changed files with 283 additions and 210 deletions

View File

@@ -45,12 +45,6 @@ ecm_add_test(
TEST_NAME chatbarcachetest TEST_NAME chatbarcachetest
) )
ecm_add_test(
chatdocumenthandlertest.cpp
LINK_LIBRARIES neochat Qt::Test
TEST_NAME chatdocumenthandlertest
)
ecm_add_test( ecm_add_test(
timelinemessagemodeltest.cpp timelinemessagemodeltest.cpp
LINK_LIBRARIES neochat Qt::Test LINK_LIBRARIES neochat Qt::Test

View File

@@ -1,36 +0,0 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include <QObject>
#include <QTest>
#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"

View File

@@ -14,14 +14,13 @@ TestCase {
} }
TextEdit { TextEdit {
id: TextEdit id: textEdit
} }
function test_empty(): void { function test_empty(): void {
compare(documentHandler.type, LibNeoChat.ChatBarType.None); compare(documentHandler.type, LibNeoChat.ChatBarType.None);
compare(documentHandler.room, null); compare(documentHandler.room, null);
compare(documentHandler.textItem, null); compare(documentHandler.textItem, null);
compare(documentHandler.completionModel instanceof LibNeoChat.CompletionModel, true);
compare(documentHandler.atFirstLine, false); compare(documentHandler.atFirstLine, false);
compare(documentHandler.atLastLine, false); compare(documentHandler.atLastLine, false);
compare(documentHandler.bold, false); compare(documentHandler.bold, false);

View File

@@ -150,7 +150,6 @@ QQC2.Control {
CompletionMenu { CompletionMenu {
id: completionMenu id: completionMenu
chatDocumentHandler: contentModel.focusedDocumentHandler chatDocumentHandler: contentModel.focusedDocumentHandler
connection: root.connection
x: 1 x: 1
y: -height y: -height

View File

@@ -16,12 +16,7 @@ import org.kde.neochat
QQC2.Popup { QQC2.Popup {
id: root id: root
required property NeoChatConnection connection
required property var chatDocumentHandler required property var chatDocumentHandler
onChatDocumentHandlerChanged: if (chatDocumentHandler) {
chatDocumentHandler.completionModel.roomListModel = RoomManager.roomListModel;
chatDocumentHandler.completionModel.userListModel = RoomManager.userListModel;
}
visible: completions.count > 0 visible: completions.count > 0
@@ -38,7 +33,7 @@ QQC2.Popup {
} }
function complete() { function complete() {
root.chatDocumentHandler.complete(completions.currentIndex); root.chatDocumentHandler.insertCompletion(completions.currentItem.replacedText, completions.currentItem.hRef)
} }
leftPadding: 0 leftPadding: 0
@@ -65,7 +60,11 @@ QQC2.Popup {
ListView { ListView {
id: completions id: completions
model: root.chatDocumentHandler.completionModel model: CompletionModel {
textItem: root.chatDocumentHandler.textItem
roomListModel: RoomManager.roomListModel
userListModel: RoomManager.userListModel
}
currentIndex: 0 currentIndex: 0
keyNavigationWraps: true keyNavigationWraps: true
highlightMoveDuration: 100 highlightMoveDuration: 100
@@ -77,6 +76,8 @@ QQC2.Popup {
required property string displayName required property string displayName
required property string subtitle required property string subtitle
required property string iconName required property string iconName
required property string replacedText
required property url hRef
text: displayName text: displayName
@@ -96,7 +97,7 @@ QQC2.Popup {
subtitleItem.textFormat: Text.PlainText subtitleItem.textFormat: Text.PlainText
} }
} }
onClicked: root.chatDocumentHandler.complete(completionDelegate.index) onClicked: root.chatDocumentHandler.insertCompletion(replacedText, hRef)
} }
} }
} }

View File

@@ -21,6 +21,7 @@ target_sources(LibNeoChat PRIVATE
neochatdatetime.cpp neochatdatetime.cpp
nestedlisthelper_p.h nestedlisthelper_p.h
nestedlisthelper.cpp nestedlisthelper.cpp
qmltextitemwrapper.cpp
roomlastmessageprovider.cpp roomlastmessageprovider.cpp
spacehierarchycache.cpp spacehierarchycache.cpp
texthandler.cpp texthandler.cpp

View File

@@ -10,6 +10,7 @@
#include <QStringBuilder> #include <QStringBuilder>
#include <QSyntaxHighlighter> #include <QSyntaxHighlighter>
#include <QTextBlock> #include <QTextBlock>
#include <QTextBoundaryFinder>
#include <QTextDocument> #include <QTextDocument>
#include <QTextDocumentFragment> #include <QTextDocumentFragment>
#include <QTextList> #include <QTextList>
@@ -84,6 +85,7 @@ public:
setFormat(error.first, error.second.size(), errorFormat); setFormat(error.first, error.second.size(), errorFormat);
} }
} }
auto handler = dynamic_cast<ChatDocumentHandler *>(parent()); auto handler = dynamic_cast<ChatDocumentHandler *>(parent());
auto room = handler->room(); auto room = handler->room();
if (!room) { if (!room) {
@@ -109,6 +111,7 @@ public:
mention.cursor.setPosition(mention.cursor.anchor() + mention.text.size(), QTextCursor::KeepAnchor); mention.cursor.setPosition(mention.cursor.anchor() + mention.text.size(), QTextCursor::KeepAnchor);
} }
qWarning() << mention.cursor.selectedText() << mention.text;
if (mention.cursor.selectedText() != mention.text) { if (mention.cursor.selectedText() != mention.text) {
return true; return true;
} }
@@ -128,29 +131,12 @@ private:
ChatDocumentHandler::ChatDocumentHandler(QObject *parent) ChatDocumentHandler::ChatDocumentHandler(QObject *parent)
: QObject(parent) : QObject(parent)
, m_markdownHelper(new ChatMarkdownHelper(this))
, m_highlighter(new SyntaxHighlighter(this)) , m_highlighter(new SyntaxHighlighter(this))
, m_completionModel(new CompletionModel(this))
{ {
m_markdownHelper = new ChatMarkdownHelper(this);
connect(this, &ChatDocumentHandler::formatChanged, m_markdownHelper, &ChatMarkdownHelper::handleExternalFormatChange); 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 ChatBarType::Type ChatDocumentHandler::type() const
{ {
return m_type; return m_type;
@@ -177,7 +163,6 @@ void ChatDocumentHandler::setRoom(NeoChatRoom *room)
} }
m_room = room; m_room = room;
m_completionModel->setRoom(m_room);
Q_EMIT roomChanged(); Q_EMIT roomChanged();
} }
@@ -200,8 +185,8 @@ void ChatDocumentHandler::setTextItem(QQuickItem *textItem)
} }
m_textItem = textItem; m_textItem = textItem;
m_highlighter->setDocument(document()); m_highlighter->setDocument(document());
if (m_textItem) { if (m_textItem) {
connect(m_textItem, SIGNAL(cursorPositionChanged()), this, SLOT(updateCursor())); connect(m_textItem, SIGNAL(cursorPositionChanged()), this, SLOT(updateCursor()));
if (document()) { if (document()) {
@@ -338,9 +323,6 @@ int ChatDocumentHandler::cursorPosition() const
void ChatDocumentHandler::updateCursor() void ChatDocumentHandler::updateCursor()
{ {
int start = completionStartIndex();
m_completionModel->setText(getText().mid(start, cursorPosition() - start), getText().mid(start));
Q_EMIT atFirstLineChanged(); Q_EMIT atFirstLineChanged();
Q_EMIT atLastLineChanged(); 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 QString ChatDocumentHandler::getText() const
{ {
if (!document()) { if (!document()) {
@@ -861,6 +778,39 @@ void ChatDocumentHandler::insertTable(int rows, int columns)
return; 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) void ChatDocumentHandler::updateLink(const QString &linkUrl, const QString &linkText)
{ {
auto cursor = textCursor(); auto cursor = textCursor();

View File

@@ -14,7 +14,6 @@
#include "chatmarkdownhelper.h" #include "chatmarkdownhelper.h"
#include "enums/chatbartype.h" #include "enums/chatbartype.h"
#include "enums/richformat.h" #include "enums/richformat.h"
#include "models/completionmodel.h"
#include "neochatroom.h" #include "neochatroom.h"
#include "nestedlisthelper_p.h" #include "nestedlisthelper_p.h"
@@ -85,14 +84,6 @@ class ChatDocumentHandler : public QObject
*/ */
Q_PROPERTY(QQuickItem *textItem READ textItem WRITE setTextItem NOTIFY textItemChanged) 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. * @brief Whether the cursor is currently on the first line.
*/ */
@@ -155,10 +146,6 @@ public:
QTextDocumentFragment takeFirstBlock(); QTextDocumentFragment takeFirstBlock();
void fillFragments(bool &hasBefore, QTextDocumentFragment &midFragment, std::optional<QTextDocumentFragment> &afterFragment); void fillFragments(bool &hasBefore, QTextDocumentFragment &midFragment, std::optional<QTextDocumentFragment> &afterFragment);
Q_INVOKABLE void complete(int index);
CompletionModel *completionModel() const;
/** /**
* @brief Update the mentions in @p document when editing a message. * @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 updateLink(const QString &linkUrl, const QString &linkText);
Q_INVOKABLE void insertImage(const QUrl &imagePath); Q_INVOKABLE void insertImage(const QUrl &imagePath);
Q_INVOKABLE void insertTable(int rows, int columns); Q_INVOKABLE void insertTable(int rows, int columns);
Q_INVOKABLE void insertCompletion(const QString &text, const QUrl &link);
Q_INVOKABLE void dumpHtml(); Q_INVOKABLE void dumpHtml();
Q_INVOKABLE QString htmlText() const; Q_INVOKABLE QString htmlText() const;
@@ -248,9 +236,6 @@ private:
SyntaxHighlighter *m_highlighter = nullptr; SyntaxHighlighter *m_highlighter = nullptr;
int completionStartIndex() const;
CompletionModel *m_completionModel = nullptr;
QString getText() const; QString getText() const;
void pushMention(const Mention mention) const; void pushMention(const Mention mention) const;

View File

@@ -2,35 +2,55 @@
// SPDX-License-Identifier: LGPL-2.0-or-later // SPDX-License-Identifier: LGPL-2.0-or-later
#include "completionmodel.h" #include "completionmodel.h"
#include <QDebug> #include <QDebug>
#include <QTextCursor>
#include "completionproxymodel.h" #include "completionproxymodel.h"
#include "models/actionsmodel.h" #include "models/actionsmodel.h"
#include "models/customemojimodel.h" #include "models/customemojimodel.h"
#include "models/emojimodel.h" #include "models/emojimodel.h"
#include "neochatroom.h" #include "qmltextitemwrapper.h"
#include "userlistmodel.h" #include "userlistmodel.h"
CompletionModel::CompletionModel(QObject *parent) CompletionModel::CompletionModel(QObject *parent)
: QAbstractListModel(parent) : QAbstractListModel(parent)
, m_textItem(new QmlTextItemWrapper(this))
, m_filterModel(new CompletionProxyModel(this)) , m_filterModel(new CompletionProxyModel(this))
, m_emojiModel(new QConcatenateTablesProxyModel(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(&CustomEmojiModel::instance());
m_emojiModel->addSourceModel(&EmojiModel::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_textItem->setTextItem(textItem);
m_fullText = fullText; }
Q_EMIT textChanged();
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 int CompletionModel::rowCount(const QModelIndex &parent) const
@@ -58,6 +78,12 @@ QVariant CompletionModel::data(const QModelIndex &index, int role) const
if (role == IconNameRole) { if (role == IconNameRole) {
return m_filterModel->data(filterIndex, UserListModel::AvatarRole); 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) { if (m_autoCompletionType == Command) {
@@ -85,6 +111,12 @@ QVariant CompletionModel::data(const QModelIndex &index, int role) const
if (role == IconNameRole) { if (role == IconNameRole) {
return m_filterModel->data(filterIndex, RoomListModel::AvatarRole).toString(); 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 (m_autoCompletionType == Emoji) {
if (role == DisplayNameRole) { if (role == DisplayNameRole) {
@@ -111,44 +143,57 @@ QHash<int, QByteArray> CompletionModel::roleNames() const
{SubtitleRole, "subtitle"}, {SubtitleRole, "subtitle"},
{IconNameRole, "iconName"}, {IconNameRole, "iconName"},
{ReplacedTextRole, "replacedText"}, {ReplacedTextRole, "replacedText"},
{HRefRole, "hRef"},
}; };
} }
void CompletionModel::updateCompletion() 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->setSourceModel(m_userListModel);
m_filterModel->setFilterRole(UserListModel::UserIdRole); m_filterModel->setFilterRole(UserListModel::UserIdRole);
m_filterModel->setSecondaryFilterRole(UserListModel::DisplayNameRole); m_filterModel->setSecondaryFilterRole(UserListModel::DisplayNameRole);
m_filterModel->setFullText(m_fullText); m_filterModel->setFullText(fullText);
m_filterModel->setFilterText(m_text); m_filterModel->setFilterText(text);
m_autoCompletionType = User; m_autoCompletionType = User;
m_filterModel->invalidate(); m_filterModel->invalidate();
} else if (text().startsWith(QLatin1Char('/'))) { } else if (text.startsWith(QLatin1Char('/'))) {
m_filterModel->setSourceModel(&ActionsModel::instance()); m_filterModel->setSourceModel(&ActionsModel::instance());
m_filterModel->setFilterRole(ActionsModel::Prefix); m_filterModel->setFilterRole(ActionsModel::Prefix);
m_filterModel->setSecondaryFilterRole(-1); m_filterModel->setSecondaryFilterRole(-1);
m_filterModel->setFullText(m_fullText); m_filterModel->setFullText(fullText);
m_filterModel->setFilterText(m_text.mid(1)); m_filterModel->setFilterText(text.mid(1));
m_autoCompletionType = Command; m_autoCompletionType = Command;
m_filterModel->invalidate(); m_filterModel->invalidate();
} else if (text().startsWith(QLatin1Char('#'))) { } else if (text.startsWith(QLatin1Char('#'))) {
m_autoCompletionType = Room; m_autoCompletionType = Room;
m_filterModel->setSourceModel(m_roomListModel); m_filterModel->setSourceModel(m_roomListModel);
m_filterModel->setFilterRole(RoomListModel::CanonicalAliasRole); m_filterModel->setFilterRole(RoomListModel::CanonicalAliasRole);
m_filterModel->setSecondaryFilterRole(RoomListModel::DisplayNameRole); m_filterModel->setSecondaryFilterRole(RoomListModel::DisplayNameRole);
m_filterModel->setFullText(m_fullText); m_filterModel->setFullText(fullText);
m_filterModel->setFilterText(m_text); m_filterModel->setFilterText(text);
m_filterModel->invalidate(); m_filterModel->invalidate();
} else if (text().startsWith(QLatin1Char(':')) && text().size() > 1 && !text()[1].isUpper() } else if (text.startsWith(QLatin1Char(':')) && text.size() > 1 && !text[1].isUpper()
&& (m_fullText.indexOf(QLatin1Char(':'), 1) == -1 && (fullText.indexOf(QLatin1Char(':'), 1) == -1
|| (m_fullText.indexOf(QLatin1Char(' ')) != -1 && m_fullText.indexOf(QLatin1Char(':'), 1) > m_fullText.indexOf(QLatin1Char(' '), 1)))) { || (fullText.indexOf(QLatin1Char(' ')) != -1 && fullText.indexOf(QLatin1Char(':'), 1) > fullText.indexOf(QLatin1Char(' '), 1)))) {
m_filterModel->setSourceModel(m_emojiModel); m_filterModel->setSourceModel(m_emojiModel);
m_autoCompletionType = Emoji; m_autoCompletionType = Emoji;
m_filterModel->setFilterRole(CustomEmojiModel::Name); m_filterModel->setFilterRole(CustomEmojiModel::Name);
m_filterModel->setSecondaryFilterRole(EmojiModel::DescriptionRole); m_filterModel->setSecondaryFilterRole(EmojiModel::DescriptionRole);
m_filterModel->setFullText(m_fullText); m_filterModel->setFullText(fullText);
m_filterModel->setFilterText(m_text); m_filterModel->setFilterText(text);
m_filterModel->invalidate(); m_filterModel->invalidate();
} else { } else {
m_autoCompletionType = None; m_autoCompletionType = None;
@@ -157,17 +202,6 @@ void CompletionModel::updateCompletion()
endResetModel(); endResetModel();
} }
NeoChatRoom *CompletionModel::room() const
{
return m_room;
}
void CompletionModel::setRoom(NeoChatRoom *room)
{
m_room = room;
Q_EMIT roomChanged();
}
CompletionModel::AutoCompletionType CompletionModel::autoCompletionType() const CompletionModel::AutoCompletionType CompletionModel::autoCompletionType() const
{ {
return m_autoCompletionType; return m_autoCompletionType;

View File

@@ -5,13 +5,14 @@
#include <QConcatenateTablesProxyModel> #include <QConcatenateTablesProxyModel>
#include <QQmlEngine> #include <QQmlEngine>
#include <QQuickItem>
#include <QSortFilterProxyModel> #include <QSortFilterProxyModel>
#include "roomlistmodel.h" #include "roomlistmodel.h"
class CompletionProxyModel; class CompletionProxyModel;
class UserListModel; class UserListModel;
class NeoChatRoom; class QmlTextItemWrapper;
class RoomListModel; class RoomListModel;
/** /**
@@ -28,14 +29,9 @@ class CompletionModel : public QAbstractListModel
QML_ELEMENT 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) Q_PROPERTY(QQuickItem *textItem READ textItem WRITE setTextItem NOTIFY textItemChanged)
/**
* @brief The current room that the model is getting completions for.
*/
Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged)
/** /**
* @brief The current type of completion being done on the entered text. * @brief The current type of completion being done on the entered text.
@@ -72,14 +68,18 @@ public:
*/ */
enum Roles { enum Roles {
DisplayNameRole = Qt::DisplayRole, /**< The main text to show. */ 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. */ IconNameRole, /**< The icon to show. */
ReplacedTextRole, /**< The text to replace the input text with for the completion. */ ReplacedTextRole, /**< The text to replace the input text with for the completion. */
HRefRole, /**< The hyperlink if applicable for the completion. */
}; };
Q_ENUM(Roles) Q_ENUM(Roles)
explicit CompletionModel(QObject *parent = nullptr); explicit CompletionModel(QObject *parent = nullptr);
QQuickItem *textItem() const;
void setTextItem(QQuickItem *textItem);
/** /**
* @brief Get the given role value at the given index. * @brief Get the given role value at the given index.
* *
@@ -101,12 +101,6 @@ public:
*/ */
QHash<int, QByteArray> roleNames() const override; QHash<int, QByteArray> roleNames() const override;
QString text() const;
void setText(const QString &text, const QString &fullText);
NeoChatRoom *room() const;
void setRoom(NeoChatRoom *room);
RoomListModel *roomListModel() const; RoomListModel *roomListModel() const;
void setRoomListModel(RoomListModel *roomListModel); void setRoomListModel(RoomListModel *roomListModel);
@@ -117,17 +111,20 @@ public:
void setAutoCompletionType(AutoCompletionType autoCompletionType); void setAutoCompletionType(AutoCompletionType autoCompletionType);
Q_SIGNALS: Q_SIGNALS:
void textChanged(); void textItemChanged();
void roomChanged(); void roomChanged();
void autoCompletionTypeChanged(); void autoCompletionTypeChanged();
void roomListModelChanged(); void roomListModelChanged();
void userListModelChanged(); void userListModelChanged();
private: private:
QString m_text; QPointer<QmlTextItemWrapper> m_textItem;
QString m_fullText;
int m_textStart = 0;
void updateTextStart();
CompletionProxyModel *m_filterModel; CompletionProxyModel *m_filterModel;
QPointer<NeoChatRoom> m_room;
AutoCompletionType m_autoCompletionType = None; AutoCompletionType m_autoCompletionType = None;
void updateCompletion(); void updateCompletion();

View File

@@ -0,0 +1,98 @@
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include "qmltextitemwrapper.h"
#include <QQuickTextDocument>
#include <QTextCursor>
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<QQuickTextDocument *>(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"

View File

@@ -0,0 +1,51 @@
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QObject>
#include <QQuickItem>
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<QQuickItem> m_textItem;
int cursorPosition() const;
int selectionStart() const;
int selectionEnd() const;
private Q_SLOTS:
void textDocCursorChanged();
};

View File

@@ -110,7 +110,7 @@ QQC2.Control {
height: implicitHeight height: implicitHeight
y: -height - 5 y: -height - 5
z: 10 z: 10
connection: root.Message.room.connection as NeoChatConnection
chatDocumentHandler: documentHandler chatDocumentHandler: documentHandler
margins: 0 margins: 0
Behavior on height { Behavior on height {