diff --git a/autotests/chatmarkdownhelpertest.qml b/autotests/chatmarkdownhelpertest.qml index 9ed992e3b..5f26e4d8f 100644 --- a/autotests/chatmarkdownhelpertest.qml +++ b/autotests/chatmarkdownhelpertest.qml @@ -77,8 +77,8 @@ TestCase { {tag: "inline code", input: "`c` ", outText: ["`", "c", "c`", "c "], outFormats: [[], [RichFormat.InlineCode], [RichFormat.InlineCode], []], unhandled: 0}, {tag: "code", input: "``` ", outText: ["`", "``", "```", " "], outFormats: [[], [], [], []], unhandled: 1}, {tag: "strikethrough", input: "~~s~~ ", outText: ["~", "~~", "s", "s~", "s~~", "s "], outFormats: [[], [], [RichFormat.Strikethrough], [RichFormat.Strikethrough], [RichFormat.Strikethrough], []], unhandled: 0}, - {tag: "underline", input: "__u__ ", outText: ["_", "__", "u", "u_", "u__", "u "], outFormats: [[], [], [RichFormat.Underline], [RichFormat.Underline], [RichFormat.Underline], []], unhandled: 0}, - {tag: "multiple closable", input: "***__~~t~~__*** ", outText: ["*", "**", "*", "_", "__", "~", "~~", "t", "t~", "t~~", "t_", "t__", "t*", "t**", "t*", "t "], outFormats: [[], [], [RichFormat.Bold], [RichFormat.Bold, RichFormat.Italic], [RichFormat.Bold, RichFormat.Italic], [RichFormat.Bold, RichFormat.Italic, RichFormat.Underline], [RichFormat.Bold, RichFormat.Italic, RichFormat.Underline], [RichFormat.Bold, RichFormat.Italic, RichFormat.Underline, RichFormat.Strikethrough], [RichFormat.Bold, RichFormat.Italic, RichFormat.Underline, RichFormat.Strikethrough], [RichFormat.Bold, RichFormat.Italic, RichFormat.Underline, RichFormat.Strikethrough], [RichFormat.Bold, RichFormat.Italic, RichFormat.Underline], [RichFormat.Bold, RichFormat.Italic, RichFormat.Underline], [RichFormat.Bold, RichFormat.Italic], [RichFormat.Bold, RichFormat.Italic], [RichFormat.Italic], []], unhandled: 0}, + {tag: "underline", input: "_u_ ", outText: ["_", "u", "u_", "u "], outFormats: [[], [RichFormat.Underline], [RichFormat.Underline], []], unhandled: 0}, + {tag: "multiple closable", input: "***_~~t~~_*** ", outText: ["*", "**", "*", "_", "~", "~~", "t", "t~", "t~~", "t_", "t*", "t**", "t*", "t "], outFormats: [[], [], [RichFormat.Bold], [RichFormat.Bold, RichFormat.Italic], [RichFormat.Bold, RichFormat.Italic, RichFormat.Underline], [RichFormat.Bold, RichFormat.Italic, RichFormat.Underline], [RichFormat.Bold, RichFormat.Italic, RichFormat.Underline, RichFormat.Strikethrough], [RichFormat.Bold, RichFormat.Italic, RichFormat.Underline, RichFormat.Strikethrough], [RichFormat.Bold, RichFormat.Italic, RichFormat.Underline, RichFormat.Strikethrough], [RichFormat.Bold, RichFormat.Italic, RichFormat.Underline], [RichFormat.Bold, RichFormat.Italic], [RichFormat.Bold, RichFormat.Italic], [RichFormat.Italic], []], unhandled: 0}, {tag: "nonclosable closable", input: "* **b** ", outText: ["*", "* ", "*", "**", "b", "b*", "b**", "b "], outFormats: [[], [], [RichFormat.UnorderedList], [RichFormat.UnorderedList], [RichFormat.Bold, RichFormat.UnorderedList], [RichFormat.Bold, RichFormat.UnorderedList], [RichFormat.Bold, RichFormat.UnorderedList], [RichFormat.UnorderedList]], unhandled: 0}, {tag: "not at line start", input: " 1) ", outText: [" ", " 1", " 1)", " 1) "], outFormats: [[], [], [], []], unhandled: 0}, ] @@ -96,4 +96,77 @@ TestCase { compare(spyUnhandledFormat.count, data.unhandled); } + + function test_backspace(): void { + keyClick("*"); + compare(chatMarkdownHelper.checkText("*"), true); + compare(chatMarkdownHelper.checkFormats([]), true); + keyClick("*"); + compare(chatMarkdownHelper.checkText("**"), true); + compare(chatMarkdownHelper.checkFormats([]), true); + keyClick("b"); + compare(chatMarkdownHelper.checkText("b"), true); + compare(chatMarkdownHelper.checkFormats([RichFormat.Bold]), true); + keyClick("o"); + compare(chatMarkdownHelper.checkText("bo"), true); + compare(chatMarkdownHelper.checkFormats([RichFormat.Bold]), true); + keyClick("l"); + compare(chatMarkdownHelper.checkText("bol"), true); + compare(chatMarkdownHelper.checkFormats([RichFormat.Bold]), true); + keyClick("d"); + compare(chatMarkdownHelper.checkText("bold"), true); + compare(chatMarkdownHelper.checkFormats([RichFormat.Bold]), true); + keyClick(Qt.Key_Backspace); + compare(chatMarkdownHelper.checkText("bol"), true); + compare(chatMarkdownHelper.checkFormats([RichFormat.Bold]), true); + keyClick(Qt.Key_Backspace); + compare(chatMarkdownHelper.checkText("bo"), true); + compare(chatMarkdownHelper.checkFormats([RichFormat.Bold]), true); + keyClick("*"); + compare(chatMarkdownHelper.checkText("bo*"), true); + compare(chatMarkdownHelper.checkFormats([RichFormat.Bold]), true); + keyClick("*"); + compare(chatMarkdownHelper.checkText("bo**"), true); + compare(chatMarkdownHelper.checkFormats([RichFormat.Bold]), true); + keyClick(" "); + compare(chatMarkdownHelper.checkText("bo "), true); + compare(chatMarkdownHelper.checkFormats([]), true); + } + + function test_cursorMove(): void { + keyClick("t"); + keyClick("e"); + keyClick("s"); + keyClick("t"); + compare(chatMarkdownHelper.checkText("test"), true); + compare(chatMarkdownHelper.checkFormats([]), true); + keyClick("*"); + keyClick("*"); + keyClick("b"); + compare(chatMarkdownHelper.checkText("testb"), true); + compare(chatMarkdownHelper.checkFormats([RichFormat.Bold]), true); + textEdit.cursorPosition = 2; + keyClick("*"); + keyClick("*"); + keyClick("b"); + compare(chatMarkdownHelper.checkText("tebstb"), true); + compare(chatMarkdownHelper.checkFormats([]), true); + } + + function test_insertText(): void { + textEdit.insert(0, "test"); + compare(chatMarkdownHelper.checkText("test"), true); + compare(chatMarkdownHelper.checkFormats([]), true); + textEdit.insert(4, "**b"); + compare(chatMarkdownHelper.checkText("testb"), true); + compare(chatMarkdownHelper.checkFormats([RichFormat.Bold]), true); + + textEdit.clear(); + textEdit.insert(0, "test"); + compare(chatMarkdownHelper.checkText("test"), true); + compare(chatMarkdownHelper.checkFormats([]), true); + textEdit.insert(2, "**b"); + compare(chatMarkdownHelper.checkText("tebst"), true); + compare(chatMarkdownHelper.checkFormats([RichFormat.Bold]), true); + } } diff --git a/autotests/chatmarkdownhelpertestwrapper.h b/autotests/chatmarkdownhelpertestwrapper.h index e5b054b3d..c117f8a19 100644 --- a/autotests/chatmarkdownhelpertestwrapper.h +++ b/autotests/chatmarkdownhelpertestwrapper.h @@ -49,6 +49,7 @@ public: if (!doc) { return false; } + // qWarning() << doc->toPlainText() <toPlainText(); } @@ -58,6 +59,7 @@ public: if (cursor.isNull()) { return false; } + // qWarning() << RichFormat::formatsAtCursor(cursor) < checkSequence(const QString ¤tString, const QString &n return std::nullopt; } +bool checkSequenceBackwards(const QString ¤tString) +{ + auto it = syntax.cbegin(); + while ((it = std::find_if(it, + syntax.cend(), + [currentString](const MarkdownSyntax &syntax) { + return syntax.sequence.endsWith(currentString); + })) + != syntax.cend()) { + return true; + } + return false; +} + std::optional syntaxForSequence(const QString &sequence) { const auto it = std::find_if(syntax.cbegin(), syntax.cend(), [sequence](const MarkdownSyntax &syntax) { @@ -118,75 +132,93 @@ void ChatMarkdownHelper::setTextItem(ChatTextItemHelper *textItem) void ChatMarkdownHelper::updateStart() { - m_startPos = *m_textItem->cursorPosition(); - m_endPos = m_startPos; - if (m_startPos == 0) { - m_currentState = Pre; + if (!m_textItem) { + return; + } + const auto newCursorPosition = m_textItem->cursorPosition(); + if (newCursorPosition) { + updatePosition(*newCursorPosition); } } void ChatMarkdownHelper::checkMarkdown(int position, int charsRemoved, int charsAdded) { + updatePosition(position); + + // This can happen when formatting is applied. + if (charsAdded == charsRemoved) { + return; + } auto cursor = m_textItem->textCursor(); if (cursor.isNull()) { return; } - if (charsRemoved - charsAdded > 0) { - if (position < m_startPos) { - m_startPos = position; - } - m_endPos -= charsRemoved; - cursor.setPosition(m_endPos); - cursor.setPosition(m_endPos + (cursor.atBlockEnd() ? 0 : 1), QTextCursor::KeepAnchor); - const auto nextChar = cursor.selectedText(); - m_currentState = m_startPos == 0 || nextChar == u' ' ? Pre : None; + if (charsRemoved > charsAdded) { + updatePosition(std::max(0, position - charsRemoved + charsAdded)); + } + + checkMarkdownForward(charsAdded - charsRemoved); +} + +void ChatMarkdownHelper::updatePosition(int position) +{ + if (position == m_endPos) { return; } - for (auto i = 1; i <= charsAdded - charsRemoved; ++i) { + m_startPos = position; + m_endPos = position; + + if (m_startPos <= 0) { + return; + } + + auto cursor = m_textItem->textCursor(); + if (cursor.isNull()) { + return; + } + cursor.setPosition(m_startPos); + cursor.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor); + while (checkSequenceBackwards(cursor.selectedText()) && m_startPos > 0) { + --m_startPos; + cursor.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor); + } +} + +void ChatMarkdownHelper::checkMarkdownForward(int charsAdded) +{ + if (charsAdded <= 0) { + return; + } + auto cursor = m_textItem->textCursor(); + if (cursor.isNull()) { + return; + } + + for (auto i = 1; i <= charsAdded; ++i) { cursor.setPosition(m_startPos); + const auto atBlockStart = cursor.atBlockStart(); cursor.setPosition(m_endPos, QTextCursor::KeepAnchor); const auto currentMarkdown = cursor.selectedText(); cursor.setPosition(m_endPos); cursor.setPosition(m_endPos + 1, QTextCursor::KeepAnchor); const auto nextChar = cursor.selectedText(); - cursor.setPosition(m_startPos); - const auto result = checkSequence(currentMarkdown, nextChar, cursor.atBlockStart()); - - switch (m_currentState) { - case None: - if (nextChar == u' ' || cursor.atBlockEnd()) { - m_currentState = Pre; - } + const auto result = checkSequence(currentMarkdown, nextChar, atBlockStart); + if (!result) { ++m_startPos; m_endPos = m_startPos; - break; - case Pre: - if (!result && RichFormat::formatsAtCursor(cursor).length() == 0) { - m_currentState = None; - } else if (result && !*result) { - m_currentState = Started; - ++m_endPos; - break; - } - ++m_startPos; - m_endPos = m_startPos; - break; - case Started: - if (!result) { - m_currentState = Pre; - ++m_startPos; - m_endPos = m_startPos; - break; - } else if (!*result) { - ++m_endPos; - break; - } - complete(); - break; + continue; + ; } + if (!*result) { + ++m_endPos; + continue; + ; + } + + complete(); } } @@ -200,29 +232,24 @@ void ChatMarkdownHelper::complete() cursor.setPosition(m_startPos); cursor.setPosition(m_endPos, QTextCursor::KeepAnchor); const auto syntax = syntaxForSequence(cursor.selectedText()); + if (!syntax) { + return; + } cursor.removeSelectedText(); - if (m_currentFormats.contains(syntax->format)) { - m_currentFormats.remove(syntax->format); - } else if (syntax->closable) { - m_currentFormats.insert(syntax->format, m_startPos); - } - cursor.setPosition(m_startPos); - cursor.setPosition(m_startPos + 1, QTextCursor::KeepAnchor); + cursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor); const auto nextChar = cursor.selectedText(); const auto result = checkSequence({}, nextChar, cursor.atBlockStart()); - m_currentState = result ? Started : Pre; - // cursor.setPosition(m_startPos + 1); - cursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor); - if (syntax) { - const auto formatType = RichFormat::typeForFormat(syntax->format); - if (formatType == RichFormat::Block) { - Q_EMIT unhandledBlockFormat(syntax->format); - } else { - m_textItem->mergeFormatOnCursor(syntax->format, cursor); - } + cursor.setPosition(m_startPos); + cursor.movePosition(QTextCursor::NextWord, QTextCursor::KeepAnchor); + + const auto formatType = RichFormat::typeForFormat(syntax->format); + if (formatType == RichFormat::Block) { + Q_EMIT unhandledBlockFormat(syntax->format); + } else { + m_textItem->mergeFormatOnCursor(syntax->format, cursor); } m_startPos = result ? m_startPos : m_startPos + 1; @@ -231,14 +258,4 @@ void ChatMarkdownHelper::complete() cursor.endEditBlock(); } -void ChatMarkdownHelper::handleExternalFormatChange() -{ - auto cursor = m_textItem->textCursor(); - if (cursor.isNull()) { - return; - } - cursor.setPosition(m_startPos); - m_currentState = RichFormat::formatsAtCursor(cursor).length() > 0 ? Pre : None; -} - #include "moc_chatmarkdownhelper.cpp" diff --git a/src/libneochat/chatmarkdownhelper.h b/src/libneochat/chatmarkdownhelper.h index 1cf785df7..3b33c541a 100644 --- a/src/libneochat/chatmarkdownhelper.h +++ b/src/libneochat/chatmarkdownhelper.h @@ -22,8 +22,6 @@ public: ChatTextItemHelper *textItem() const; void setTextItem(ChatTextItemHelper *textItem); - void handleExternalFormatChange(); - Q_SIGNALS: void textItemChanged(); @@ -36,21 +34,14 @@ Q_SIGNALS: void unhandledBlockFormat(RichFormat::Format format); private: - enum State { - None, - Pre, - Started, - }; - QPointer m_textItem; - State m_currentState = None; int m_startPos = 0; int m_endPos = 0; void updateStart(); - QHash m_currentFormats; - void checkMarkdown(int position, int charsRemoved, int charsAdded); + void updatePosition(int position); + void checkMarkdownForward(int charsAdded); void complete(); }; diff --git a/src/libneochat/enums/richformat.cpp b/src/libneochat/enums/richformat.cpp index a4d44d101..efdb4f13d 100644 --- a/src/libneochat/enums/richformat.cpp +++ b/src/libneochat/enums/richformat.cpp @@ -160,6 +160,14 @@ bool RichFormat::hasFormat(QTextCursor cursor, Format format) } } +bool RichFormat::hasAnyFormat(QTextCursor cursor, QList formats) +{ + for (const auto &format : formats) { + return hasFormat(cursor, format); + } + return false; +} + QList RichFormat::formatsAtCursor(const QTextCursor &cursor) { QList formats; diff --git a/src/libneochat/enums/richformat.h b/src/libneochat/enums/richformat.h index 5e4c36354..894a51fdd 100644 --- a/src/libneochat/enums/richformat.h +++ b/src/libneochat/enums/richformat.h @@ -107,6 +107,13 @@ public: */ static bool hasFormat(QTextCursor cursor, Format format); + /** + * @brief Whether the given QTextCursor has any of the given Formats. + * + * @sa Format, QTextCursor + */ + static bool hasAnyFormat(QTextCursor cursor, QList formats); + /** * @brief A lsit of Formats on the given QTextCursor. *