Refactor input stuff
This is the start of a significant refactoring of everything related to sending messages, which is roughly:
- the chatbox
- action handling
- message sending on the c++ side
- autocompletion of users/rooms/emojis/commands/things i forgot
Notable changes so far include:
- ChatBox is now a ColumnLayout. As part of this, i removed the height animations for now. <del>as far as i can tell, they were broken anyway.</del> I'll readd them later
- Actions were refactored to live outside of the message sending function and are now each an object; it's mostly a wrapper around a function that is executed when the action is invoked
- Everything that used to live in ChatBoxHelper is now in NeoChatRoom; that means that the exact input status (text, message being replied to, message being edited, attachment) is now saved between room switching).
- To edit/reply an event, set `NeoChatRoom::chatBox{edit,reply}Id` to the desired event id, `NeoChatRoom::chatBox{reply,edit}{User,Message}` will then be updated automatically
- Attachments behave equivalently with `NeoChatRoom::chatBoxAttachmentPath`
- Error message reporting from ActionsHandler has been fixed (same fix as in !517) and moved to NeoChatRoom
Broken at the moment:
- [x] Any kind of autocompletion
- [x] Mentions
- [x] Fancy effects
- [x] sed-style edits
- [x] last-user-message edits and replies
- [x] Some of the actions, probably
- [x] Replies from notifications
- [x] Lots of keyboard shortcuts
- [x] Custom emojis
- [x] ChatBox height animations
TODO:
- [x] User / room mentions based on QTextCursors instead of the hack we currently use
- [x] Refactor autocompletion stuff
- [x] ???
- [x] Profit
This commit is contained in:
@@ -7,18 +7,138 @@
|
||||
#include <QQmlFileSelector>
|
||||
#include <QQuickTextDocument>
|
||||
#include <QStringBuilder>
|
||||
#include <QSyntaxHighlighter>
|
||||
#include <QTextBlock>
|
||||
#include <QTextDocument>
|
||||
|
||||
#include <Sonnet/BackgroundChecker>
|
||||
#include <Sonnet/Settings>
|
||||
|
||||
#include "actionsmodel.h"
|
||||
#include "completionmodel.h"
|
||||
#include "neochatroom.h"
|
||||
#include "roomlistmodel.h"
|
||||
|
||||
class SyntaxHighlighter : public QSyntaxHighlighter
|
||||
{
|
||||
public:
|
||||
QTextCharFormat mentionFormat;
|
||||
QTextCharFormat errorFormat;
|
||||
Sonnet::BackgroundChecker *checker = new Sonnet::BackgroundChecker;
|
||||
Sonnet::Settings settings;
|
||||
QList<QPair<int, QString>> errors;
|
||||
QString previousText;
|
||||
SyntaxHighlighter(QObject *parent)
|
||||
: QSyntaxHighlighter(parent)
|
||||
{
|
||||
mentionFormat.setFontWeight(QFont::Bold);
|
||||
mentionFormat.setForeground(Qt::blue);
|
||||
|
||||
errorFormat.setForeground(Qt::red);
|
||||
errorFormat.setUnderlineStyle(QTextCharFormat::SpellCheckUnderline);
|
||||
|
||||
connect(checker, &Sonnet::BackgroundChecker::misspelling, this, [this](const QString &word, int start) {
|
||||
errors += {start, word};
|
||||
rehighlight();
|
||||
checker->continueChecking();
|
||||
});
|
||||
}
|
||||
void highlightBlock(const QString &text) override
|
||||
{
|
||||
if (settings.checkerEnabledByDefault()) {
|
||||
if (text != previousText) {
|
||||
previousText = text;
|
||||
checker->stop();
|
||||
errors.clear();
|
||||
checker->setText(text);
|
||||
}
|
||||
for (const auto &error : errors) {
|
||||
setFormat(error.first, error.second.size(), errorFormat);
|
||||
}
|
||||
}
|
||||
auto room = dynamic_cast<ChatDocumentHandler *>(parent())->room();
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
auto mentions = room->mentions();
|
||||
mentions->erase(std::remove_if(mentions->begin(),
|
||||
mentions->end(),
|
||||
[this](auto &mention) {
|
||||
if (document()->toPlainText().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mention.cursor.position() == 0 && mention.cursor.anchor() == 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (mention.cursor.position() - mention.cursor.anchor() != mention.text.size()) {
|
||||
mention.cursor.setPosition(mention.start);
|
||||
mention.cursor.setPosition(mention.cursor.anchor() + mention.text.size(), QTextCursor::KeepAnchor);
|
||||
}
|
||||
|
||||
if (mention.cursor.selectedText() != mention.text) {
|
||||
return true;
|
||||
}
|
||||
if (currentBlock() == mention.cursor.block()) {
|
||||
mention.start = mention.cursor.anchor();
|
||||
mention.position = mention.cursor.position();
|
||||
setFormat(mention.cursor.selectionStart(), mention.cursor.selectedText().size(), mentionFormat);
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
mentions->end());
|
||||
}
|
||||
};
|
||||
|
||||
ChatDocumentHandler::ChatDocumentHandler(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_document(nullptr)
|
||||
, m_cursorPosition(-1)
|
||||
, m_selectionStart(-1)
|
||||
, m_selectionEnd(-1)
|
||||
, m_highlighter(new SyntaxHighlighter(this))
|
||||
, m_completionModel(new CompletionModel())
|
||||
{
|
||||
connect(this, &ChatDocumentHandler::roomChanged, this, [this]() {
|
||||
m_completionModel->setRoom(m_room);
|
||||
static NeoChatRoom *previousRoom = nullptr;
|
||||
if (previousRoom) {
|
||||
disconnect(nullptr, &NeoChatRoom::chatBoxTextChanged, this, nullptr);
|
||||
}
|
||||
previousRoom = m_room;
|
||||
connect(m_room, &NeoChatRoom::chatBoxTextChanged, this, [this]() {
|
||||
int start = completionStartIndex();
|
||||
m_completionModel->setText(m_room->chatBoxText().mid(start, cursorPosition() - start), m_room->chatBoxText().mid(start));
|
||||
});
|
||||
});
|
||||
connect(this, &ChatDocumentHandler::documentChanged, this, [this]() {
|
||||
m_highlighter->setDocument(m_document->textDocument());
|
||||
});
|
||||
connect(this, &ChatDocumentHandler::cursorPositionChanged, this, [this]() {
|
||||
if (!m_room) {
|
||||
return;
|
||||
}
|
||||
int start = completionStartIndex();
|
||||
m_completionModel->setText(m_room->chatBoxText().mid(start, cursorPosition() - start), m_room->chatBoxText().mid(start));
|
||||
});
|
||||
}
|
||||
|
||||
int ChatDocumentHandler::completionStartIndex() const
|
||||
{
|
||||
if (!m_room) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const auto &cursor = cursorPosition();
|
||||
const auto &text = m_room->chatBoxText();
|
||||
auto start = std::min(cursor, text.size()) - 1;
|
||||
while (start > -1) {
|
||||
if (text.at(start) == QLatin1Char(' ')) {
|
||||
start++;
|
||||
break;
|
||||
}
|
||||
start--;
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
QQuickTextDocument *ChatDocumentHandler::document() const
|
||||
@@ -49,67 +169,12 @@ void ChatDocumentHandler::setCursorPosition(int position)
|
||||
if (position == m_cursorPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_cursorPosition = position;
|
||||
if (m_room) {
|
||||
m_cursorPosition = position;
|
||||
}
|
||||
Q_EMIT cursorPositionChanged();
|
||||
}
|
||||
|
||||
int ChatDocumentHandler::selectionStart() const
|
||||
{
|
||||
return m_selectionStart;
|
||||
}
|
||||
|
||||
void ChatDocumentHandler::setSelectionStart(int position)
|
||||
{
|
||||
if (position == m_selectionStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_selectionStart = position;
|
||||
Q_EMIT selectionStartChanged();
|
||||
}
|
||||
|
||||
int ChatDocumentHandler::selectionEnd() const
|
||||
{
|
||||
return m_selectionEnd;
|
||||
}
|
||||
|
||||
void ChatDocumentHandler::setSelectionEnd(int position)
|
||||
{
|
||||
if (position == m_selectionEnd) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_selectionEnd = position;
|
||||
Q_EMIT selectionEndChanged();
|
||||
}
|
||||
|
||||
QTextCursor ChatDocumentHandler::textCursor() const
|
||||
{
|
||||
QTextDocument *doc = textDocument();
|
||||
if (!doc) {
|
||||
return QTextCursor();
|
||||
}
|
||||
|
||||
QTextCursor cursor = QTextCursor(doc);
|
||||
if (m_selectionStart != m_selectionEnd) {
|
||||
cursor.setPosition(m_selectionStart);
|
||||
cursor.setPosition(m_selectionEnd, QTextCursor::KeepAnchor);
|
||||
} else {
|
||||
cursor.setPosition(m_cursorPosition);
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
QTextDocument *ChatDocumentHandler::textDocument() const
|
||||
{
|
||||
if (!m_document) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
return m_document->textDocument();
|
||||
}
|
||||
|
||||
NeoChatRoom *ChatDocumentHandler::room() const
|
||||
{
|
||||
return m_room;
|
||||
@@ -125,91 +190,55 @@ void ChatDocumentHandler::setRoom(NeoChatRoom *room)
|
||||
Q_EMIT roomChanged();
|
||||
}
|
||||
|
||||
QVariantMap ChatDocumentHandler::getAutocompletionInfo(bool isAutocompleting)
|
||||
void ChatDocumentHandler::complete(int index)
|
||||
{
|
||||
QTextCursor cursor = textCursor();
|
||||
|
||||
if (cursor.block().text() == m_lastState) {
|
||||
// ignore change, it was caused by autocompletion
|
||||
return QVariantMap{
|
||||
{"type", AutoCompletionType::Ignore},
|
||||
};
|
||||
if (m_completionModel->autoCompletionType() == ChatDocumentHandler::User) {
|
||||
auto name = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::Text).toString();
|
||||
auto id = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::Subtitle).toString();
|
||||
auto text = m_room->chatBoxText();
|
||||
auto at = text.lastIndexOf(QLatin1Char('@'), cursorPosition() - 1);
|
||||
QTextCursor cursor(document()->textDocument());
|
||||
cursor.setPosition(at);
|
||||
cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor);
|
||||
cursor.insertText(name % " ");
|
||||
cursor.setPosition(at);
|
||||
cursor.setPosition(cursor.position() + name.size(), QTextCursor::KeepAnchor);
|
||||
cursor.setKeepPositionOnInsert(true);
|
||||
m_room->mentions()->push_back({cursor, name, 0, 0, id});
|
||||
m_highlighter->rehighlight();
|
||||
} else if (m_completionModel->autoCompletionType() == ChatDocumentHandler::Command) {
|
||||
auto command = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::ReplacedText).toString();
|
||||
auto text = m_room->chatBoxText();
|
||||
auto at = text.lastIndexOf(QLatin1Char('/'));
|
||||
QTextCursor cursor(document()->textDocument());
|
||||
cursor.setPosition(at);
|
||||
cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor);
|
||||
cursor.insertText(QStringLiteral("/%1 ").arg(command));
|
||||
} else if (m_completionModel->autoCompletionType() == ChatDocumentHandler::Room) {
|
||||
auto alias = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::Subtitle).toString();
|
||||
auto text = m_room->chatBoxText();
|
||||
auto at = text.lastIndexOf(QLatin1Char('#'), cursorPosition() - 1);
|
||||
QTextCursor cursor(document()->textDocument());
|
||||
cursor.setPosition(at);
|
||||
cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor);
|
||||
cursor.insertText(alias % " ");
|
||||
cursor.setPosition(at);
|
||||
cursor.setPosition(cursor.position() + alias.size(), QTextCursor::KeepAnchor);
|
||||
cursor.setKeepPositionOnInsert(true);
|
||||
m_room->mentions()->push_back({cursor, alias, 0, 0, alias});
|
||||
m_highlighter->rehighlight();
|
||||
} else if (m_completionModel->autoCompletionType() == ChatDocumentHandler::Emoji) {
|
||||
auto shortcode = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::Text).toString();
|
||||
auto text = m_room->chatBoxText();
|
||||
auto at = text.lastIndexOf(QLatin1Char(':'));
|
||||
QTextCursor cursor(document()->textDocument());
|
||||
cursor.setPosition(at);
|
||||
cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor);
|
||||
cursor.insertText(shortcode);
|
||||
}
|
||||
|
||||
QString text = cursor.block().text();
|
||||
QString textBeforeCursor = text;
|
||||
textBeforeCursor.truncate(m_cursorPosition);
|
||||
|
||||
QString autoCompletePrefix = textBeforeCursor.section(" ", -1);
|
||||
|
||||
if (autoCompletePrefix.isEmpty()) {
|
||||
return QVariantMap{
|
||||
{"type", AutoCompletionType::None},
|
||||
};
|
||||
}
|
||||
|
||||
if (autoCompletePrefix.startsWith("@") || autoCompletePrefix.startsWith(":") || autoCompletePrefix.startsWith("/")) {
|
||||
m_autoCompleteBeginPosition = textBeforeCursor.lastIndexOf(" ") + 1; // 1 == space
|
||||
|
||||
if (autoCompletePrefix.startsWith("@")) {
|
||||
autoCompletePrefix.remove(0, 1);
|
||||
return QVariantMap{
|
||||
{"keyword", autoCompletePrefix},
|
||||
{"type", AutoCompletionType::User},
|
||||
};
|
||||
}
|
||||
|
||||
if (autoCompletePrefix.startsWith("/") && text.trimmed().length() <= 1) {
|
||||
return QVariantMap{
|
||||
{"keyword", autoCompletePrefix},
|
||||
{"type", AutoCompletionType::Command},
|
||||
};
|
||||
}
|
||||
|
||||
if (!isAutocompleting) {
|
||||
return QVariantMap{
|
||||
{"keyword", autoCompletePrefix},
|
||||
{"type", AutoCompletionType::Emoji},
|
||||
};
|
||||
} else {
|
||||
return QVariantMap{
|
||||
{"type", AutoCompletionType::Ignore},
|
||||
{"keyword", autoCompletePrefix},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return QVariantMap{
|
||||
{"type", AutoCompletionType::None},
|
||||
};
|
||||
}
|
||||
|
||||
void ChatDocumentHandler::replaceAutoComplete(const QString &word)
|
||||
CompletionModel *ChatDocumentHandler::completionModel() const
|
||||
{
|
||||
QTextCursor cursor = textCursor();
|
||||
if (cursor.block().text() == m_lastState) {
|
||||
m_document->textDocument()->undo();
|
||||
}
|
||||
cursor.beginEditBlock();
|
||||
cursor.select(QTextCursor::WordUnderCursor);
|
||||
cursor.removeSelectedText();
|
||||
cursor.deletePreviousChar();
|
||||
while (!cursor.atBlockStart()) {
|
||||
cursor.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor);
|
||||
|
||||
if (cursor.selectedText() == " ") {
|
||||
cursor.movePosition(QTextCursor::NextCharacter);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
cursor.insertHtml(word);
|
||||
|
||||
// Add space after autocomplete if not already there
|
||||
if (!cursor.block().text().endsWith(QStringLiteral(" "))) {
|
||||
cursor.insertText(QStringLiteral(" "));
|
||||
}
|
||||
|
||||
m_lastState = cursor.block().text();
|
||||
cursor.endEditBlock();
|
||||
return m_completionModel;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user