diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt index 1e20ad284..3adf1af01 100644 --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -120,7 +120,10 @@ macro(add_qml_tests) endforeach() endmacro() -add_executable(qmltest qmltest.cpp qmltextitemwrappertestwrapper.h) +add_executable(qmltest qmltest.cpp + chatmarkdownhelpertestwrapper.h + qmltextitemwrappertestwrapper.h +) qt_add_qml_module(qmltest URI NeoChatTestUtils) target_link_libraries(qmltest @@ -133,5 +136,6 @@ target_link_libraries(qmltest add_qml_tests( chatdocumenthelpertest.qml + chatmarkdownhelpertest.qml qmltextitemwrappertest.qml ) diff --git a/autotests/chatmarkdownhelpertest.qml b/autotests/chatmarkdownhelpertest.qml new file mode 100644 index 000000000..96af8b4a9 --- /dev/null +++ b/autotests/chatmarkdownhelpertest.qml @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: 2024 Carl Schwan +// SPDX-License-Identifier: LGPL-2.0-or-later + +import QtQuick +import QtTest + +import org.kde.neochat.libneochat + +import NeoChatTestUtils + +TestCase { + name: "ChatMarkdownHelperTest" + + TextEdit { + id: textEdit + + textFormat: TextEdit.RichText + } + + TextEdit { + id: textEdit2 + } + + ChatMarkdownHelperTestWrapper { + id: chatMarkdownHelper + + textItem: textEdit + } + + SignalSpy { + id: spyItem + target: chatMarkdownHelper + signalName: "textItemChanged" + } + + SignalSpy { + id: spyUnhandledFormat + target: chatMarkdownHelper + signalName: "unhandledBlockFormat" + } + + function initTestCase(): void { + textEdit.forceActiveFocus(); + } + + function cleanup(): void { + chatMarkdownHelper.clear(); + compare(chatMarkdownHelper.checkText(""), true); + compare(chatMarkdownHelper.checkFormats([]), true); + compare(textEdit.cursorPosition, 0); + } + + function test_item(): void { + spyItem.clear(); + compare(chatMarkdownHelper.textItem, textEdit); + compare(spyItem.count, 0); + chatMarkdownHelper.textItem = textEdit2; + compare(chatMarkdownHelper.textItem, textEdit2); + compare(spyItem.count, 1); + chatMarkdownHelper.textItem = textEdit; + compare(chatMarkdownHelper.textItem, textEdit); + compare(spyItem.count, 2); + } + + function test_textFormat_data() { + return [ + {tag: "bold", input: "**b** ", outText: ["*", "**", "b", "b*", "b**", "b "], outFormats: [[], [], [RichFormat.Bold], [RichFormat.Bold], [RichFormat.Bold], []], unhandled: 0}, + {tag: "italic", input: "*i* ", outText: ["*", "i", "i*", "i "], outFormats: [[], [RichFormat.Italic], [RichFormat.Italic], []], unhandled: 0}, + {tag: "heading 1", input: "# h", outText: ["#", "# ", "h"], outFormats: [[], [], [RichFormat.Bold, RichFormat.Heading1]], unhandled: 0}, + {tag: "heading 2", input: "## h", outText: ["#", "##", "## ", "h"], outFormats: [[], [], [], [RichFormat.Bold, RichFormat.Heading2]], unhandled: 0}, + {tag: "heading 3", input: "### h", outText: ["#", "##", "###", "### ", "h"], outFormats: [[], [], [], [], [RichFormat.Bold, RichFormat.Heading3]], unhandled: 0}, + {tag: "heading 4", input: "#### h", outText: ["#", "##", "###", "####", "#### ", "h"], outFormats: [[], [], [], [], [], [RichFormat.Bold, RichFormat.Heading4]], unhandled: 0}, + {tag: "heading 5", input: "##### h", outText: ["#", "##", "###", "####", "#####", "##### ", "h"], outFormats: [[], [], [], [], [], [], [RichFormat.Bold, RichFormat.Heading5]], unhandled: 0}, + {tag: "heading 6", input: "###### h", outText: ["#", "##", "###", "####", "#####", "######", "###### ", "h"], outFormats: [[], [], [], [], [], [] ,[], [RichFormat.Bold, RichFormat.Heading6]], unhandled: 0}, + {tag: "quote", input: "> q", outText: [">", "> ", "q"], outFormats: [[], [], []], unhandled: 1}, + {tag: "unorderedlist 1", input: "* l", outText: ["*", "* ", "l"], outFormats: [[], [], [RichFormat.UnorderedList]], unhandled: 0}, + {tag: "unorderedlist 2", input: "- l", outText: ["-", "- ", "l"], outFormats: [[], [], [RichFormat.UnorderedList]], unhandled: 0}, + {tag: "orderedlist 1", input: "1. l", outText: ["1", "1.", "1. ", "l"], outFormats: [[], [], [], [RichFormat.OrderedList]], unhandled: 0}, + {tag: "orderedlist 2", input: "1) l", outText: ["1", "1)", "1) ", "l"], outFormats: [[], [], [], [RichFormat.OrderedList]], unhandled: 0}, + {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: "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}, + ] + } + + function test_textFormat(data): void { + spyUnhandledFormat.clear(); + compare(spyUnhandledFormat.count, 0); + + for (let i = 0; i < data.input.length; i++) { + keyClick(data.input[i]); + compare(chatMarkdownHelper.checkText(data.outText[i]), true); + compare(chatMarkdownHelper.checkFormats(data.outFormats[i]), true); + } + + compare(spyUnhandledFormat.count, data.unhandled); + } +} diff --git a/autotests/chatmarkdownhelpertestwrapper.h b/autotests/chatmarkdownhelpertestwrapper.h new file mode 100644 index 000000000..d2e4ecbb0 --- /dev/null +++ b/autotests/chatmarkdownhelpertestwrapper.h @@ -0,0 +1,84 @@ +// 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 +#include +#include + +#include "chatmarkdownhelper.h" +#include "enums/richformat.h" +#include "qmltextitemwrapper.h" + +class ChatMarkdownHelperTestWrapper : public QObject +{ + Q_OBJECT + QML_ELEMENT + + /** + * @brief The QML text Item the ChatDocumentHandler is handling. + */ + Q_PROPERTY(QQuickItem *textItem READ textItem WRITE setTextItem NOTIFY textItemChanged) + +public: + explicit ChatMarkdownHelperTestWrapper(QObject *parent = nullptr) + : QObject(parent) + , m_chatMarkdownHelper(new ChatMarkdownHelper(this)) + , m_textItem(new QmlTextItemWrapper(this)) + { + connect(m_chatMarkdownHelper, &ChatMarkdownHelper::textItemChanged, this, &ChatMarkdownHelperTestWrapper::textItemChanged); + connect(m_chatMarkdownHelper, &ChatMarkdownHelper::unhandledBlockFormat, this, &ChatMarkdownHelperTestWrapper::unhandledBlockFormat); + } + + QQuickItem *textItem() const + { + return m_chatMarkdownHelper->textItem(); + } + void setTextItem(QQuickItem *textItem) + { + m_chatMarkdownHelper->setTextItem(textItem); + m_textItem->setTextItem(textItem); + } + + Q_INVOKABLE bool checkText(const QString &text) + { + const auto doc = m_textItem->document(); + if (!doc) { + return false; + } + qWarning() << text << doc->toPlainText(); + return text == doc->toPlainText(); + } + + Q_INVOKABLE bool checkFormats(QList formats) + { + const auto cursor = m_textItem->textCursor(); + if (cursor.isNull()) { + return false; + } + qWarning() << RichFormat::formatsAtCursor(cursor) << formats; + return RichFormat::formatsAtCursor(cursor) == formats; + } + + Q_INVOKABLE void clear() + { + auto cursor = m_textItem->textCursor(); + if (cursor.isNull()) { + return; + } + cursor.select(QTextCursor::Document); + cursor.removeSelectedText(); + cursor.setBlockCharFormat(RichFormat::charFormatForFormat(RichFormat::Paragraph)); + cursor.setBlockFormat(RichFormat::blockFormatForFormat(RichFormat::Paragraph)); + } + +Q_SIGNALS: + void textItemChanged(); + void unhandledBlockFormat(RichFormat::Format format); + +private: + QPointer m_chatMarkdownHelper; + QPointer m_textItem; +}; diff --git a/autotests/qmltextitemwrappertest.qml b/autotests/qmltextitemwrappertest.qml index 09ad7bd9e..95ddf88cc 100644 --- a/autotests/qmltextitemwrappertest.qml +++ b/autotests/qmltextitemwrappertest.qml @@ -70,6 +70,7 @@ TestCase { spyCursor.clear(); // We can't get to the QTextCursor from QML so we have to use a helper function. compare(qmlTextItemWrapper.compareCursor(textEdit.cursorPosition, textEdit.selectionStart, textEdit.selectionEnd), true); + compare(textEdit.cursorPosition, qmlTextItemWrapper.cursorPosition()); textEdit.insert(0, "test text") compare(spyContentsChange.count, 1); compare(spyContentsChange.signalArguments[0][0], 0); @@ -78,10 +79,12 @@ TestCase { compare(spyContentsChanged.count, 1); compare(spyCursor.count, 1); compare(qmlTextItemWrapper.compareCursor(textEdit.cursorPosition, textEdit.selectionStart, textEdit.selectionEnd), true); + compare(textEdit.cursorPosition, qmlTextItemWrapper.cursorPosition()); textEdit.selectAll(); compare(spyContentsChanged.count, 1); compare(spyCursor.count, 1); compare(qmlTextItemWrapper.compareCursor(textEdit.cursorPosition, textEdit.selectionStart, textEdit.selectionEnd), true); + compare(textEdit.cursorPosition, qmlTextItemWrapper.cursorPosition()); textEdit.clear(); compare(spyContentsChange.count, 2); compare(spyContentsChange.signalArguments[1][0], 0); diff --git a/autotests/qmltextitemwrappertestwrapper.h b/autotests/qmltextitemwrappertestwrapper.h index cb7170bbf..34c63ab03 100644 --- a/autotests/qmltextitemwrappertestwrapper.h +++ b/autotests/qmltextitemwrappertestwrapper.h @@ -61,6 +61,11 @@ public: return posSame && startSame && endSame; } + Q_INVOKABLE int cursorPosition() const + { + return m_textItemWrapper->cursorPosition(); + } + Q_INVOKABLE void setCursorPosition(int pos) { m_textItemWrapper->setCursorPosition(pos); diff --git a/src/libneochat/chatdocumenthandler.cpp b/src/libneochat/chatdocumenthandler.cpp index b5656be12..10803d158 100644 --- a/src/libneochat/chatdocumenthandler.cpp +++ b/src/libneochat/chatdocumenthandler.cpp @@ -136,6 +136,8 @@ ChatDocumentHandler::ChatDocumentHandler(QObject *parent) , m_markdownHelper(new ChatMarkdownHelper(this)) , m_highlighter(new SyntaxHighlighter(this)) { + connectTextItem(); + connect(this, &ChatDocumentHandler::formatChanged, m_markdownHelper, &ChatMarkdownHelper::handleExternalFormatChange); } @@ -176,6 +178,7 @@ QQuickItem *ChatDocumentHandler::textItem() const void ChatDocumentHandler::setTextItem(QQuickItem *textItem) { m_textItem->setTextItem(textItem); + m_markdownHelper->setTextItem(textItem); } void ChatDocumentHandler::connectTextItem() @@ -209,6 +212,10 @@ void ChatDocumentHandler::connectTextItem() } } }); + connect(m_textItem, &QmlTextItemWrapper::formatChanged, this, &ChatDocumentHandler::formatChanged); + connect(m_textItem, &QmlTextItemWrapper::textFormatChanged, this, &ChatDocumentHandler::textFormatChanged); + connect(m_textItem, &QmlTextItemWrapper::styleChanged, this, &ChatDocumentHandler::styleChanged); + connect(m_textItem, &QmlTextItemWrapper::listChanged, this, &ChatDocumentHandler::listChanged); } ChatDocumentHandler *ChatDocumentHandler::previousDocumentHandler() const @@ -756,65 +763,36 @@ void ChatDocumentHandler::regenerateColorScheme() void ChatDocumentHandler::setFormat(RichFormat::Format format) { - switch (RichFormat::typeForFormat(format)) { - case RichFormat::Text: - setTextFormat(format); - return; - case RichFormat::List: - setListFormat(format); - return; - case RichFormat::Style: - setStyleFormat(format); - return; - default: + QTextCursor cursor = m_textItem->textCursor(); + if (cursor.isNull()) { return; } -} - -void ChatDocumentHandler::indentListMore() -{ - m_nestedListHelper.handleOnIndentMore(m_textItem->textCursor()); - Q_EMIT currentListStyleChanged(); -} - -void ChatDocumentHandler::indentListLess() -{ - m_nestedListHelper.handleOnIndentLess(m_textItem->textCursor()); - Q_EMIT currentListStyleChanged(); -} - -void ChatDocumentHandler::setListFormat(RichFormat::Format format) -{ - m_nestedListHelper.handleOnBulletType(RichFormat::listStyleForFormat(format), m_textItem->textCursor()); - Q_EMIT currentListStyleChanged(); -} - -bool ChatDocumentHandler::canIndentList() const -{ - return m_nestedListHelper.canIndent(m_textItem->textCursor()) && m_textItem->textCursor().blockFormat().headingLevel() == 0; -} - -bool ChatDocumentHandler::canDedentList() const -{ - return m_nestedListHelper.canDedent(m_textItem->textCursor()) && m_textItem->textCursor().blockFormat().headingLevel() == 0; + m_textItem->mergeFormatOnCursor(format, cursor); } int ChatDocumentHandler::currentListStyle() const { - if (!m_textItem->textCursor().currentList()) { - return 0; - } - - return -m_textItem->textCursor().currentList()->format().style(); + return m_textItem->currentListStyle(); } -void ChatDocumentHandler::setTextFormat(RichFormat::Format format) +bool ChatDocumentHandler::canIndentListMore() const { - if (RichFormat::typeForFormat(format) != RichFormat::Text) { - return; - } - mergeFormatOnWordOrSelection(RichFormat::charFormatForFormat(format, RichFormat::hasFormat(m_textItem->textCursor(), format))); - Q_EMIT formatChanged(); + return m_textItem->canIndentListMore(); +} + +bool ChatDocumentHandler::canIndentListLess() const +{ + return m_textItem->canIndentListLess(); +} + +void ChatDocumentHandler::indentListMore() +{ + m_textItem->indentListMore(); +} + +void ChatDocumentHandler::indentListLess() +{ + m_textItem->indentListLess(); } RichFormat::Format ChatDocumentHandler::style() const @@ -822,48 +800,6 @@ RichFormat::Format ChatDocumentHandler::style() const return static_cast(m_textItem->textCursor().blockFormat().headingLevel()); } -void ChatDocumentHandler::setStyleFormat(RichFormat::Format format) -{ - // Paragraph is special because it is normally a Block format but if we're already - // in a Paragraph it clears any existing style. - if (!(RichFormat::typeForFormat(format) == RichFormat::Style || format == RichFormat::Paragraph)) { - return; - } - - QTextCursor cursor = m_textItem->textCursor(); - if (cursor.isNull()) { - return; - } - cursor.beginEditBlock(); - - cursor.mergeBlockFormat(RichFormat::blockFormatForFormat(format)); - - // Applying style to the current line or selection - QTextCursor selectCursor = cursor; - if (selectCursor.hasSelection()) { - QTextCursor top = selectCursor; - top.setPosition(qMin(top.anchor(), top.position())); - top.movePosition(QTextCursor::StartOfBlock); - - QTextCursor bottom = selectCursor; - bottom.setPosition(qMax(bottom.anchor(), bottom.position())); - bottom.movePosition(QTextCursor::EndOfBlock); - - selectCursor.setPosition(top.position(), QTextCursor::MoveAnchor); - selectCursor.setPosition(bottom.position(), QTextCursor::KeepAnchor); - } else { - selectCursor.select(QTextCursor::BlockUnderCursor); - } - - const auto chrfmt = RichFormat::charFormatForFormat(format); - selectCursor.mergeCharFormat(chrfmt); - cursor.mergeBlockCharFormat(chrfmt); - cursor.endEditBlock(); - - Q_EMIT formatChanged(); - Q_EMIT styleChanged(); -} - void ChatDocumentHandler::tab() { QTextCursor cursor = m_textItem->textCursor(); diff --git a/src/libneochat/chatdocumenthandler.h b/src/libneochat/chatdocumenthandler.h index ba89c9bbc..909f95226 100644 --- a/src/libneochat/chatdocumenthandler.h +++ b/src/libneochat/chatdocumenthandler.h @@ -97,14 +97,14 @@ class ChatDocumentHandler : public QObject Q_PROPERTY(QColor textColor READ textColor WRITE setTextColor NOTIFY textColorChanged) - Q_PROPERTY(bool bold READ bold NOTIFY formatChanged) - Q_PROPERTY(bool italic READ italic NOTIFY formatChanged) - Q_PROPERTY(bool underline READ underline NOTIFY formatChanged) - Q_PROPERTY(bool strikethrough READ strikethrough NOTIFY formatChanged) + Q_PROPERTY(bool bold READ bold NOTIFY textFormatChanged) + Q_PROPERTY(bool italic READ italic NOTIFY textFormatChanged) + Q_PROPERTY(bool underline READ underline NOTIFY textFormatChanged) + Q_PROPERTY(bool strikethrough READ strikethrough NOTIFY textFormatChanged) Q_PROPERTY(RichFormat::Format style READ style NOTIFY styleChanged) - Q_PROPERTY(int currentListStyle READ currentListStyle NOTIFY currentListStyleChanged) + Q_PROPERTY(int currentListStyle READ currentListStyle NOTIFY listChanged) public: enum InsertPosition { @@ -161,11 +161,10 @@ public: bool strikethrough() const; Q_INVOKABLE void setFormat(RichFormat::Format format); - void setFormatOnCursor(RichFormat::Format format, const QTextCursor &cursor); - bool canIndentList() const; - bool canDedentList() const; int currentListStyle() const; + bool canIndentListMore() const; + bool canIndentListLess() const; Q_INVOKABLE void indentListLess(); Q_INVOKABLE void indentListMore(); @@ -195,11 +194,12 @@ Q_SIGNALS: void textColorChanged(); - void checkableChanged(); void currentListStyleChanged(); void formatChanged(); + void textFormatChanged(); void styleChanged(); + void listChanged(); void contentsChanged(); @@ -220,10 +220,6 @@ private: QString m_initialText = {}; void initializeChars(); - void setTextFormat(RichFormat::Format format); - void setStyleFormat(RichFormat::Format format); - void setListFormat(RichFormat::Format format); - QPointer m_markdownHelper; std::optional m_pendingFormat = std::nullopt; std::optional m_pendingOverrideFormat = std::nullopt; @@ -236,7 +232,6 @@ private: std::optional textFormat() const; void mergeFormatOnWordOrSelection(const QTextCharFormat &format); void selectLinkText(QTextCursor *cursor) const; - NestedListHelper m_nestedListHelper; QColor linkColor(); QColor mLinkColor; void regenerateColorScheme(); diff --git a/src/libneochat/chatmarkdownhelper.cpp b/src/libneochat/chatmarkdownhelper.cpp index 1e56269bd..5ef8f878c 100644 --- a/src/libneochat/chatmarkdownhelper.cpp +++ b/src/libneochat/chatmarkdownhelper.cpp @@ -3,11 +3,15 @@ #include "chatmarkdownhelper.h" +#include #include #include -#include "chatdocumenthandler.h" +#include "qmltextitemwrapper.h" +#include "richformat.h" +namespace +{ struct MarkdownSyntax { QLatin1String sequence; bool closable = false; @@ -15,7 +19,7 @@ struct MarkdownSyntax { RichFormat::Format format; }; -static const QList syntax = { +const QList syntax = { MarkdownSyntax{.sequence = "*"_L1, .closable = true, .format = RichFormat::Italic}, MarkdownSyntax{.sequence = "**"_L1, .closable = true, .format = RichFormat::Bold}, MarkdownSyntax{.sequence = "# "_L1, .lineStart = true, .format = RichFormat::Heading1}, @@ -35,7 +39,7 @@ static const QList syntax = { MarkdownSyntax{.sequence = "__"_L1, .closable = true, .format = RichFormat::Underline}, }; -static std::optional checkSequence(const QString ¤tString, const QString &nextChar, bool lineStart = false) +std::optional checkSequence(const QString ¤tString, const QString &nextChar, bool lineStart = false) { QList partialMatches; std::optional fullMatch = std::nullopt; @@ -65,7 +69,7 @@ static std::optional checkSequence(const QString ¤tString, const QSt return std::nullopt; } -static std::optional syntaxForSequence(const QString &sequence) +std::optional syntaxForSequence(const QString &sequence) { const auto it = std::find_if(syntax.cbegin(), syntax.cend(), [sequence](const MarkdownSyntax &syntax) { return syntax.sequence == sequence; @@ -75,55 +79,41 @@ static std::optional syntaxForSequence(const QString &sequence) } return *it; } +} -ChatMarkdownHelper::ChatMarkdownHelper(ChatDocumentHandler *parent) +ChatMarkdownHelper::ChatMarkdownHelper(QObject *parent) : QObject(parent) + , m_textItem(new QmlTextItemWrapper(this)) { - Q_ASSERT(parent); - - connectDocument(); - connect(parent, &ChatDocumentHandler::textItemChanged, this, &ChatMarkdownHelper::connectDocument); + connectTextItem(); } -QTextDocument *ChatMarkdownHelper::document() const +QQuickItem *ChatMarkdownHelper::textItem() const { - const auto documentHandler = qobject_cast(parent()); - if (!documentHandler) { - return nullptr; - } - - if (!documentHandler->textItem()) { - return nullptr; - } - - const auto quickDocument = qvariant_cast(documentHandler->textItem()->property("textDocument")); - return quickDocument ? quickDocument->textDocument() : nullptr; + return m_textItem->textItem(); } -void ChatMarkdownHelper::connectDocument() +void ChatMarkdownHelper::setTextItem(QQuickItem *textItem) { - disconnect(); + m_textItem->setTextItem(textItem); +} - if (document()) { - m_startPos = qobject_cast(parent())->textItem()->property("cursorPosition").toInt(); +void ChatMarkdownHelper::connectTextItem() +{ + connect(m_textItem, &QmlTextItemWrapper::textItemChanged, this, &ChatMarkdownHelper::textItemChanged); + connect(m_textItem, &QmlTextItemWrapper::textItemChanged, this, [this]() { + m_startPos = m_textItem->cursorPosition(); m_endPos = m_startPos; if (m_startPos == 0) { m_currentState = Pre; } - - connect(document(), &QTextDocument::contentsChange, this, &ChatMarkdownHelper::checkMarkdown); - } + }); + connect(m_textItem, &QmlTextItemWrapper::textDocumentContentsChange, this, &ChatMarkdownHelper::checkMarkdown); } void ChatMarkdownHelper::checkMarkdown(int position, int charsRemoved, int charsAdded) { - qWarning() << "1" << m_currentState << m_startPos << m_endPos; - - if (!document()) { - return; - } - - auto cursor = QTextCursor(document()); + auto cursor = m_textItem->textCursor(); if (cursor.isNull()) { return; } @@ -137,7 +127,6 @@ void ChatMarkdownHelper::checkMarkdown(int position, int charsRemoved, int chars cursor.setPosition(m_endPos + (cursor.atBlockEnd() ? 0 : 1), QTextCursor::KeepAnchor); const auto nextChar = cursor.selectedText(); m_currentState = m_startPos == 0 || nextChar == u' ' ? Pre : None; - qWarning() << "2" << m_currentState << m_startPos << m_endPos; return; } @@ -151,7 +140,7 @@ void ChatMarkdownHelper::checkMarkdown(int position, int charsRemoved, int chars cursor.setPosition(m_startPos); const auto result = checkSequence(currentMarkdown, nextChar, cursor.atBlockStart()); - qWarning() << result; + qWarning() << m_startPos << m_endPos << result; switch (m_currentState) { case None: @@ -186,16 +175,15 @@ void ChatMarkdownHelper::checkMarkdown(int position, int charsRemoved, int chars break; } } - - qWarning() << "2" << m_currentState << m_startPos << m_endPos; } void ChatMarkdownHelper::complete() { - auto cursor = QTextCursor(document()); + auto cursor = m_textItem->textCursor(); if (cursor.isNull()) { return; } + cursor.beginEditBlock(); cursor.setPosition(m_startPos); cursor.setPosition(m_endPos, QTextCursor::KeepAnchor); const auto syntax = syntaxForSequence(cursor.selectedText()); @@ -207,29 +195,38 @@ void ChatMarkdownHelper::complete() m_currentFormats.insert(syntax->format, m_startPos); } - ++m_startPos; + cursor.setPosition(m_startPos); + cursor.setPosition(m_startPos + 1, QTextCursor::KeepAnchor); + const auto nextChar = cursor.selectedText(); + const auto result = checkSequence({}, nextChar, cursor.atBlockStart()); + m_currentState = result ? Started : Pre; - const auto documentHandler = qobject_cast(parent()); + // cursor.setPosition(m_startPos + 1); + cursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor); if (syntax) { - documentHandler->textItem()->setProperty("cursorPosition", m_startPos); - documentHandler->setFormat(syntax->format); + const auto formatType = RichFormat::typeForFormat(syntax->format); + if (formatType == RichFormat::Block) { + Q_EMIT unhandledBlockFormat(syntax->format); + } else { + m_textItem->mergeFormatOnCursor(syntax->format, cursor); + } } - m_currentState = Pre; - m_endPos = m_startPos; + m_startPos = result ? m_startPos : m_startPos + 1; + m_endPos = result ? m_startPos + 1 : m_startPos; - documentHandler->textItem()->setProperty("cursorPosition", m_startPos); + cursor.endEditBlock(); + qWarning() << m_currentState << m_startPos << m_endPos << m_textItem->cursorPosition(); } void ChatMarkdownHelper::handleExternalFormatChange() { - auto cursor = QTextCursor(document()); + auto cursor = m_textItem->textCursor(); if (cursor.isNull()) { return; } cursor.setPosition(m_startPos); m_currentState = RichFormat::formatsAtCursor(cursor).length() > 0 ? Pre : None; - qWarning() << "3" << m_currentState << m_startPos << m_endPos; } #include "moc_chatmarkdownhelper.cpp" diff --git a/src/libneochat/chatmarkdownhelper.h b/src/libneochat/chatmarkdownhelper.h index 4e98b83f4..08814f6fb 100644 --- a/src/libneochat/chatmarkdownhelper.h +++ b/src/libneochat/chatmarkdownhelper.h @@ -4,24 +4,30 @@ #pragma once #include -#include #include "enums/richformat.h" +class QQuickItem; class QTextDocument; -class ChatDocumentHandler; +class QmlTextItemWrapper; class ChatMarkdownHelper : public QObject { Q_OBJECT - QML_ELEMENT public: - explicit ChatMarkdownHelper(ChatDocumentHandler *parent); + explicit ChatMarkdownHelper(QObject *parent = nullptr); + + QQuickItem *textItem() const; + void setTextItem(QQuickItem *textItem); void handleExternalFormatChange(); +Q_SIGNALS: + void textItemChanged(); + void unhandledBlockFormat(RichFormat::Format format); + private: enum State { None, @@ -29,8 +35,8 @@ private: Started, }; - QTextDocument *document() const; - void connectDocument(); + QPointer m_textItem; + void connectTextItem(); State m_currentState = None; int m_startPos = 0; diff --git a/src/libneochat/enums/richformat.cpp b/src/libneochat/enums/richformat.cpp index 65fe766b8..a4d44d101 100644 --- a/src/libneochat/enums/richformat.cpp +++ b/src/libneochat/enums/richformat.cpp @@ -6,7 +6,6 @@ #include #include #include -#include QString RichFormat::styleString(Format format) { @@ -68,7 +67,7 @@ QTextListFormat::Style RichFormat::listStyleForFormat(Format format) } } -QTextCharFormat RichFormat::charFormatForFormat(Format format, bool invert) +QTextCharFormat RichFormat::charFormatForFormat(Format format, bool invert, const QColor &highlightColor) { QTextCharFormat charFormat; if (format == Bold || headingLevelForFormat(format) > 0) { @@ -84,6 +83,16 @@ QTextCharFormat RichFormat::charFormatForFormat(Format format, bool invert) if (format == Strikethrough) { charFormat.setFontStrikeOut(!invert); } + if (format == InlineCode) { + if (invert) { + charFormat.setFont({}); + charFormat.setBackground({}); + } else { + charFormat.setFontFamilies({u"monospace"_s}); + charFormat.setFontFixedPitch(!invert); + charFormat.setBackground(highlightColor); + } + } if (headingLevelForFormat(format) > 0) { // Apparently, 4 is maximum for FontSizeAdjustment; otherwise level=1 and // level=2 look the same @@ -144,6 +153,8 @@ bool RichFormat::hasFormat(QTextCursor cursor, Format format) return cursor.charFormat().fontStrikeOut(); case Underline: return cursor.charFormat().fontUnderline(); + case InlineCode: + return cursor.charFormat().fontFixedPitch(); default: return false; } @@ -167,6 +178,9 @@ QList RichFormat::formatsAtCursor(const QTextCursor &cursor) if (cursor.charFormat().fontStrikeOut()) { formats += Strikethrough; } + if (cursor.charFormat().fontFixedPitch()) { + formats += InlineCode; + } if (cursor.blockFormat().headingLevel() > 0 && cursor.blockFormat().headingLevel() <= 6) { formats += formatForHeadingLevel(cursor.blockFormat().headingLevel()); } diff --git a/src/libneochat/enums/richformat.h b/src/libneochat/enums/richformat.h index 18671aacd..5e4c36354 100644 --- a/src/libneochat/enums/richformat.h +++ b/src/libneochat/enums/richformat.h @@ -91,7 +91,7 @@ public: * * @sa Format, QTextCharFormat */ - static QTextCharFormat charFormatForFormat(Format format, bool invert = false); + static QTextCharFormat charFormatForFormat(Format format, bool invert = false, const QColor &highlightColor = {}); /** * @brief Return the QTextBlockFormat for the Format. diff --git a/src/libneochat/qmltextitemwrapper.cpp b/src/libneochat/qmltextitemwrapper.cpp index a70b02e13..bd7f651c5 100644 --- a/src/libneochat/qmltextitemwrapper.cpp +++ b/src/libneochat/qmltextitemwrapper.cpp @@ -6,6 +6,8 @@ #include #include +#include + QmlTextItemWrapper::QmlTextItemWrapper(QObject *parent) : QObject(parent) { @@ -112,6 +114,133 @@ void QmlTextItemWrapper::textDocCursorChanged() Q_EMIT textDocumentCursorPositionChanged(); } +void QmlTextItemWrapper::mergeFormatOnCursor(RichFormat::Format format, const QTextCursor &cursor) +{ + if (cursor.isNull()) { + return; + } + switch (RichFormat::typeForFormat(format)) { + case RichFormat::Text: + mergeTextFormatOnCursor(format, cursor); + return; + case RichFormat::List: + mergeListFormatOnCursor(format, cursor); + return; + case RichFormat::Style: + mergeStyleFormatOnCursor(format, cursor); + return; + default: + return; + } +} + +void QmlTextItemWrapper::mergeTextFormatOnCursor(RichFormat::Format format, QTextCursor cursor) +{ + if (RichFormat::typeForFormat(format) != RichFormat::Text) { + return; + } + + const auto theme = static_cast(qmlAttachedPropertiesObject(this, true)); + const auto charFormat = RichFormat::charFormatForFormat(format, RichFormat::hasFormat(cursor, format), theme->alternateBackgroundColor()); + if (!cursor.hasSelection()) { + cursor.select(QTextCursor::WordUnderCursor); + } + cursor.mergeCharFormat(charFormat); + Q_EMIT formatChanged(); + Q_EMIT textFormatChanged(); +} + +void QmlTextItemWrapper::mergeStyleFormatOnCursor(RichFormat::Format format, QTextCursor cursor) +{ + // Paragraph is special because it is normally a Block format but if we're already + // in a Paragraph it clears any existing style. + if (!(RichFormat::typeForFormat(format) == RichFormat::Style || format == RichFormat::Paragraph)) { + return; + } + + cursor.beginEditBlock(); + cursor.mergeBlockFormat(RichFormat::blockFormatForFormat(format)); + + // Applying style to the current line or selection + QTextCursor selectCursor = cursor; + if (selectCursor.hasSelection()) { + QTextCursor top = selectCursor; + top.setPosition(qMin(top.anchor(), top.position())); + top.movePosition(QTextCursor::StartOfBlock); + + QTextCursor bottom = selectCursor; + bottom.setPosition(qMax(bottom.anchor(), bottom.position())); + bottom.movePosition(QTextCursor::EndOfBlock); + + selectCursor.setPosition(top.position(), QTextCursor::MoveAnchor); + selectCursor.setPosition(bottom.position(), QTextCursor::KeepAnchor); + } else { + selectCursor.select(QTextCursor::BlockUnderCursor); + } + + const auto chrfmt = RichFormat::charFormatForFormat(format); + selectCursor.mergeCharFormat(chrfmt); + cursor.mergeBlockCharFormat(chrfmt); + cursor.endEditBlock(); + + Q_EMIT formatChanged(); + Q_EMIT styleChanged(); +} + +void QmlTextItemWrapper::mergeListFormatOnCursor(RichFormat::Format format, const QTextCursor &cursor) +{ + m_nestedListHelper.handleOnBulletType(RichFormat::listStyleForFormat(format), cursor); + Q_EMIT formatChanged(); + Q_EMIT listChanged(); +} + +int QmlTextItemWrapper::currentListStyle() const +{ + auto cursor = textCursor(); + if (cursor.isNull() || !textCursor().currentList()) { + return 0; + } + return -cursor.currentList()->format().style(); +} + +bool QmlTextItemWrapper::canIndentListMore() const +{ + auto cursor = textCursor(); + if (cursor.isNull()) { + return false; + } + return m_nestedListHelper.canIndent(cursor) && cursor.blockFormat().headingLevel() == 0; +} + +bool QmlTextItemWrapper::canIndentListLess() const +{ + auto cursor = textCursor(); + if (cursor.isNull()) { + return false; + } + return m_nestedListHelper.canDedent(cursor) && cursor.blockFormat().headingLevel() == 0; +} + +void QmlTextItemWrapper::indentListMore() +{ + auto cursor = textCursor(); + if (cursor.isNull()) { + return; + } + m_nestedListHelper.handleOnIndentMore(cursor); + Q_EMIT listChanged(); +} + +void QmlTextItemWrapper::indentListLess() +{ + auto cursor = textCursor(); + if (cursor.isNull()) { + return; + } + m_nestedListHelper.handleOnIndentLess(cursor); + Q_EMIT listChanged(); +} + void QmlTextItemWrapper::forceActiveFocus() const { if (!m_textItem) { diff --git a/src/libneochat/qmltextitemwrapper.h b/src/libneochat/qmltextitemwrapper.h index df947a31c..7755eec11 100644 --- a/src/libneochat/qmltextitemwrapper.h +++ b/src/libneochat/qmltextitemwrapper.h @@ -6,12 +6,15 @@ #include #include +#include "enums/richformat.h" +#include "nestedlisthelper_p.h" + 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. + * A class to wrap around a QQuickItem that is a QML TextEdit (or inherited from it). * * @note This basically exists because Qt does not give us access to the cpp headers of * most QML items. @@ -31,9 +34,18 @@ public: QTextDocument *document() const; QTextCursor textCursor() const; + int cursorPosition() const; void setCursorPosition(int pos); void setCursorVisible(bool visible); + void mergeFormatOnCursor(RichFormat::Format format, const QTextCursor &cursor); + + int currentListStyle() const; + bool canIndentListMore() const; + bool canIndentListLess() const; + void indentListMore(); + void indentListLess(); + void forceActiveFocus() const; Q_SIGNALS: @@ -45,13 +57,22 @@ Q_SIGNALS: void textDocumentCursorPositionChanged(); + void formatChanged(); + void textFormatChanged(); + void styleChanged(); + void listChanged(); + private: QPointer m_textItem; - int cursorPosition() const; int selectionStart() const; int selectionEnd() const; + void mergeTextFormatOnCursor(RichFormat::Format format, QTextCursor cursor); + void mergeStyleFormatOnCursor(RichFormat::Format format, QTextCursor cursor); + void mergeListFormatOnCursor(RichFormat::Format format, const QTextCursor &cursor); + NestedListHelper m_nestedListHelper; + private Q_SLOTS: void textDocCursorChanged(); };