Add more tests

This commit is contained in:
James Graham
2026-01-02 15:04:15 +00:00
parent 9ea76ca5d0
commit d10fe4a684
18 changed files with 822 additions and 221 deletions

View File

@@ -23,14 +23,6 @@ QQC2.ToolBar {
required property MessageContent.ChatBarMessageContentModel contentModel
Connections {
target: contentModel
function onFocusRowChanged() {
console.warn("focus changed", contentModel.focusRow, contentModel.focusType)
}
}
required property real maxAvailableWidth
readonly property real uncompressedImplicitWidth: textFormatRow.implicitWidth +

View File

@@ -87,7 +87,7 @@ QQC2.Popup {
radius: Kirigami.Units.cornerRadius
border {
width: 1
color: styleDelegate.hovered || root.chatButtonHelper.currentStyle === styleDelegate.index ?
color: styleDelegate.hovered || (root.chatButtonHelper.currentStyle === styleDelegate.index) ?
Kirigami.Theme.highlightColor :
Kirigami.ColorUtils.linearInterpolation(
Kirigami.Theme.backgroundColor,

View File

@@ -44,7 +44,11 @@ bool ChatButtonHelper::bold() const
if (!m_textItem) {
return false;
}
return m_textItem->formatsAtCursor().contains(RichFormat::Bold);
const auto cursor = m_textItem->textCursor();
if (cursor.isNull()) {
return false;
}
return RichFormat::formatsAtCursor(cursor).contains(RichFormat::Bold);
}
bool ChatButtonHelper::italic() const
@@ -52,7 +56,11 @@ bool ChatButtonHelper::italic() const
if (!m_textItem) {
return false;
}
return m_textItem->formatsAtCursor().contains(RichFormat::Italic);
const auto cursor = m_textItem->textCursor();
if (cursor.isNull()) {
return false;
}
return RichFormat::formatsAtCursor(cursor).contains(RichFormat::Italic);
}
bool ChatButtonHelper::underline() const
@@ -60,7 +68,11 @@ bool ChatButtonHelper::underline() const
if (!m_textItem) {
return false;
}
return m_textItem->formatsAtCursor().contains(RichFormat::Underline);
const auto cursor = m_textItem->textCursor();
if (cursor.isNull()) {
return false;
}
return RichFormat::formatsAtCursor(cursor).contains(RichFormat::Underline);
}
bool ChatButtonHelper::strikethrough() const
@@ -68,7 +80,11 @@ bool ChatButtonHelper::strikethrough() const
if (!m_textItem) {
return false;
}
return m_textItem->formatsAtCursor().contains(RichFormat::Strikethrough);
const auto cursor = m_textItem->textCursor();
if (cursor.isNull()) {
return false;
}
return RichFormat::formatsAtCursor(cursor).contains(RichFormat::Strikethrough);
}
bool ChatButtonHelper::unorderedList() const
@@ -76,7 +92,11 @@ bool ChatButtonHelper::unorderedList() const
if (!m_textItem) {
return false;
}
return m_textItem->formatsAtCursor().contains(RichFormat::UnorderedList);
const auto cursor = m_textItem->textCursor();
if (cursor.isNull()) {
return false;
}
return RichFormat::formatsAtCursor(cursor).contains(RichFormat::UnorderedList);
}
bool ChatButtonHelper::orderedList() const
@@ -84,7 +104,11 @@ bool ChatButtonHelper::orderedList() const
if (!m_textItem) {
return false;
}
return m_textItem->formatsAtCursor().contains(RichFormat::OrderedList);
const auto cursor = m_textItem->textCursor();
if (cursor.isNull()) {
return false;
}
return RichFormat::formatsAtCursor(cursor).contains(RichFormat::OrderedList);
}
RichFormat::Format ChatButtonHelper::currentStyle() const

View File

@@ -73,7 +73,7 @@ void ChatKeyHelper::tab()
if (cursor.isNull()) {
return;
}
if (cursor.currentList()) {
if (cursor.currentList() && m_textItem->canIndentListMoreAtCursor()) {
m_textItem->indentListMoreAtCursor();
return;
}
@@ -100,7 +100,7 @@ void ChatKeyHelper::backspace()
return;
}
if (cursor.position() <= m_textItem->fixedStartChars().length()) {
if (cursor.currentList()) {
if (cursor.currentList() && m_textItem->canIndentListLessAtCursor()) {
m_textItem->indentListLessAtCursor();
return;
}

View File

@@ -36,7 +36,7 @@ const QList<MarkdownSyntax> syntax = {
MarkdownSyntax{.sequence = "`"_L1, .closable = true, .format = RichFormat::InlineCode},
MarkdownSyntax{.sequence = "```"_L1, .lineStart = true, .format = RichFormat::Code},
MarkdownSyntax{.sequence = "~~"_L1, .closable = true, .format = RichFormat::Strikethrough},
MarkdownSyntax{.sequence = "__"_L1, .closable = true, .format = RichFormat::Underline},
MarkdownSyntax{.sequence = "_"_L1, .closable = true, .format = RichFormat::Underline},
};
std::optional<bool> checkSequence(const QString &currentString, const QString &nextChar, bool lineStart = false)
@@ -104,12 +104,10 @@ void ChatMarkdownHelper::setTextItem(ChatTextItemHelper *textItem)
m_textItem = textItem;
if (m_textItem) {
connect(m_textItem, &ChatTextItemHelper::textItemChanged, this, &ChatMarkdownHelper::textItemChanged);
connect(m_textItem, &ChatTextItemHelper::textItemChanged, this, [this]() {
m_startPos = m_textItem->cursorPosition();
m_endPos = m_startPos;
if (m_startPos == 0) {
m_currentState = Pre;
connect(m_textItem, &ChatTextItemHelper::textItemChanged, this, &ChatMarkdownHelper::updateStart);
connect(m_textItem, &ChatTextItemHelper::cursorPositionChanged, this, [this](bool fromContentsChange) {
if (!fromContentsChange) {
updateStart();
}
});
connect(m_textItem, &ChatTextItemHelper::contentsChange, this, &ChatMarkdownHelper::checkMarkdown);
@@ -118,6 +116,15 @@ void ChatMarkdownHelper::setTextItem(ChatTextItemHelper *textItem)
Q_EMIT textItemChanged();
}
void ChatMarkdownHelper::updateStart()
{
m_startPos = *m_textItem->cursorPosition();
m_endPos = m_startPos;
if (m_startPos == 0) {
m_currentState = Pre;
}
}
void ChatMarkdownHelper::checkMarkdown(int position, int charsRemoved, int charsAdded)
{
auto cursor = m_textItem->textCursor();

View File

@@ -47,6 +47,7 @@ private:
State m_currentState = None;
int m_startPos = 0;
int m_endPos = 0;
void updateStart();
QHash<RichFormat::Format, int> m_currentFormats;

View File

@@ -53,6 +53,9 @@ void ChatTextItemHelper::setTextItem(QQuickItem *textItem)
connect(m_textItem, SIGNAL(cursorPositionChanged()), this, SLOT(itemCursorPositionChanged()));
if (const auto doc = document()) {
connect(doc, &QTextDocument::contentsChanged, this, &ChatTextItemHelper::contentsChanged);
connect(doc, &QTextDocument::contentsChange, this, [this]() {
m_contentsJustChanged = true;
});
connect(doc, &QTextDocument::contentsChange, this, &ChatTextItemHelper::contentsChange);
m_highlighter->setDocument(doc);
}
@@ -122,19 +125,30 @@ void ChatTextItemHelper::initializeChars()
return;
}
m_initializingChars = true;
cursor.beginEditBlock();
int finalCursorPos = cursor.position();
if (doc->isEmpty() && !m_initialText.isEmpty()) {
cursor.insertText(m_initialText);
finalCursorPos = cursor.position();
}
if (!m_fixedStartChars.isEmpty() && doc->characterAt(0) != m_fixedStartChars) {
cursor.movePosition(QTextCursor::Start);
cursor.insertText(m_fixedEndChars);
cursor.insertText(m_fixedStartChars);
finalCursorPos += m_fixedStartChars.length();
}
if (!m_fixedStartChars.isEmpty() && doc->characterAt(doc->characterCount()) != m_fixedStartChars) {
cursor.movePosition(QTextCursor::End);
cursor.keepPositionOnInsert();
cursor.insertText(m_fixedEndChars);
}
setCursorPosition(finalCursorPos);
cursor.endEditBlock();
m_initializingChars = false;
}
QTextDocument *ChatTextItemHelper::document() const
@@ -186,6 +200,10 @@ QTextDocumentFragment ChatTextItemHelper::takeFirstBlock()
const auto block = cursor.selection();
cursor.removeSelectedText();
cursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor);
if (cursor.selectedText() == QChar::ParagraphSeparator) {
cursor.removeSelectedText();
}
cursor.endEditBlock();
if (document()->characterCount() - 1 <= (m_fixedStartChars.length() + m_fixedEndChars.length())) {
Q_EMIT cleared(this);
@@ -214,18 +232,29 @@ void ChatTextItemHelper::fillFragments(bool &hasBefore, QTextDocumentFragment &m
if (!afterBlock) {
cursor.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor, m_fixedEndChars.length());
}
cursor.endEditBlock();
midFragment = cursor.selection();
if (!midFragment.isEmpty()) {
cursor.removeSelectedText();
}
cursor.deletePreviousChar();
cursor.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor);
if (cursor.selectedText() == QChar::ParagraphSeparator) {
cursor.removeSelectedText();
} else {
cursor.movePosition(QTextCursor::NextCharacter);
}
if (afterBlock) {
cursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor);
if (cursor.selectedText() == QChar::ParagraphSeparator) {
cursor.removeSelectedText();
}
cursor.movePosition(QTextCursor::End, QTextCursor::KeepAnchor);
afterFragment = cursor.selection();
cursor.removeSelectedText();
}
cursor.endEditBlock();
}
void ChatTextItemHelper::insertFragment(const QTextDocumentFragment fragment, InsertPosition position, bool keepPosition)
@@ -257,16 +286,9 @@ void ChatTextItemHelper::insertFragment(const QTextDocumentFragment fragment, In
cursor.setPosition(currentPosition);
if (textFormat() && textFormat() == Qt::PlainText) {
const auto wasEmpty = isEmpty();
auto text = fragment.toPlainText();
text = trim(text);
cursor.insertText(text);
if (wasEmpty) {
cursor.movePosition(QTextCursor::StartOfBlock);
cursor.deletePreviousChar();
cursor.movePosition(QTextCursor::EndOfBlock);
cursor.deleteChar();
}
} else {
cursor.insertMarkdown(trim(fragment.toMarkdown()));
}
@@ -276,10 +298,10 @@ void ChatTextItemHelper::insertFragment(const QTextDocumentFragment fragment, In
setCursorPosition(cursor.position());
}
int ChatTextItemHelper::cursorPosition() const
std::optional<int> ChatTextItemHelper::cursorPosition() const
{
if (!m_textItem) {
return -1;
return std::nullopt;
}
return m_textItem->property("cursorPosition").toInt();
}
@@ -311,7 +333,7 @@ QTextCursor ChatTextItemHelper::textCursor() const
cursor.setPosition(selectionStart());
cursor.setPosition(selectionEnd(), QTextCursor::KeepAnchor);
} else {
cursor.setPosition(cursorPosition());
cursor.setPosition(*cursorPosition());
}
return cursor;
}
@@ -332,7 +354,7 @@ void ChatTextItemHelper::setCursorVisible(bool visible)
m_textItem->setProperty("cursorVisible", visible);
}
void ChatTextItemHelper::setCursorFromTextItem(ChatTextItemHelper *textItem, bool infront, int defaultPosition)
void ChatTextItemHelper::setCursorFromTextItem(ChatTextItemHelper *textItem, bool infront)
{
const auto doc = document();
if (!doc) {
@@ -343,37 +365,40 @@ void ChatTextItemHelper::setCursorFromTextItem(ChatTextItemHelper *textItem, boo
if (!textItem) {
const auto docLastBlockLayout = doc->lastBlock().layout();
setCursorPosition(infront ? defaultPosition : docLastBlockLayout->lineAt(docLastBlockLayout->lineCount() - 1).textStart());
setCursorPosition(infront ? 0 : docLastBlockLayout->lineAt(docLastBlockLayout->lineCount() - 1).textStart());
setCursorVisible(true);
return;
}
const auto previousLinePosition = textItem->textCursor().positionInBlock();
const auto newMaxLineLength = lineLength(infront ? 0 : lineCount() - 1);
setCursorPosition(std::min(previousLinePosition, newMaxLineLength ? *newMaxLineLength : defaultPosition) + (infront ? 0 : doc->lastBlock().position()));
setCursorPosition(std::min(previousLinePosition, newMaxLineLength ? *newMaxLineLength : 0) + (infront ? 0 : doc->lastBlock().position()));
setCursorVisible(true);
}
void ChatTextItemHelper::itemCursorPositionChanged()
{
Q_EMIT cursorPositionChanged();
if (m_initializingChars) {
return;
}
const auto currentCursorPosition = cursorPosition();
if (!currentCursorPosition) {
return;
}
if (*currentCursorPosition < m_fixedStartChars.length() || *currentCursorPosition > document()->characterCount() - 1 - m_fixedEndChars.length()) {
setCursorPosition(
std::min(std::max(*currentCursorPosition, int(m_fixedStartChars.length())), int(document()->characterCount() - 1 - m_fixedEndChars.length())));
return;
}
Q_EMIT cursorPositionChanged(m_contentsJustChanged);
m_contentsJustChanged = false;
Q_EMIT formatChanged();
Q_EMIT textFormatChanged();
Q_EMIT styleChanged();
Q_EMIT listChanged();
}
QList<RichFormat::Format> ChatTextItemHelper::formatsAtCursor(QTextCursor cursor) const
{
if (cursor.isNull()) {
cursor = textCursor();
if (cursor.isNull()) {
return {};
}
}
return RichFormat::formatsAtCursor(cursor);
}
void ChatTextItemHelper::mergeFormatOnCursor(RichFormat::Format format, QTextCursor cursor)
{
if (cursor.isNull()) {

View File

@@ -21,10 +21,12 @@ class NeoChatRoom;
*
* 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.
* This class has 2 key functions:
* - Provide easy read/write access to the properties of the TextEdit. This is required
* because Qt does not give us access to the cpp headers of most QML items.
* - Provide standard functions to edit the underlying QTextDocument.
*
* @sa QQuickItem, TextEdit
* @sa QQuickItem, TextEdit, QTextDocument
*/
class ChatTextItemHelper : public QObject
{
@@ -45,71 +47,196 @@ public:
explicit ChatTextItemHelper(QObject *parent = nullptr);
/**
* @brief Set the NeoChatRoom required by the syntax highlighter.
*
* @sa NeoChatRoom
*/
void setRoom(NeoChatRoom *room);
/**
* @brief Set the ChatBarType::Type required by the syntax highlighter.
*
* @sa ChatBarType::Type
*/
void setType(ChatBarType::Type type);
QQuickItem *textItem() const;
void setTextItem(QQuickItem *textItem);
/**
* @brief The fixed characters that will always be at the beginning of the text item.
*/
QString fixedStartChars() const;
/**
* @brief The fixed characters that will always be at the end of the text item.
*/
QString fixedEndChars() const;
/**
* @brief Set the fixed characters that will always be at the beginning and end of the text item.
*/
void setFixedChars(const QString &startChars, const QString &endChars);
/**
* @brief Any text to initialise the text item with when set.
*/
QString initialText() const;
/**
* @brief Set the text to initialise the text item with when set.
*
* This text will only be set if the text item is empty when set.
*/
void setInitialText(const QString &text);
/**
* @brief The underlying QTextDocument.
*
* @sa QTextDocument
*/
QTextDocument *document() const;
/**
* @brief The line count of the text item.
*/
int lineCount() const;
/**
* @brief Remove the first QTextBlock from the QTextDocument and return as a QTextDocumentFragment.
*
* @sa QTextBlock, QTextDocument, QTextDocumentFragment
*/
QTextDocumentFragment takeFirstBlock();
/**
* @brief Fill the given QTextDocumentFragment with the text item contents.
*
* The idea is to split the QTextDocument into 3. There is the QTextBlock that the
* cursor is currently in, the midFragment. Then if there are any blocks after
* this they are put into the afterFragment. The if there is any block before
* the midFragment these are left and hasBefore is set to true.
*
* This is used when inserting a new block type at the cursor. The midFragement will be
* given the new style and then the before and after are put back as the same
* block type.
*
* @sa QTextBlock, QTextDocument, QTextDocumentFragment
*/
void fillFragments(bool &hasBefore, QTextDocumentFragment &midFragment, std::optional<QTextDocumentFragment> &afterFragment);
/**
* @brief Insert the given QTextDocumentFragment as the given position.
*/
void insertFragment(const QTextDocumentFragment fragment, InsertPosition position = Cursor, bool keepPosition = false);
/**
* @brief Return a QTextCursor pointing to the current cursor position.
*/
QTextCursor textCursor() const;
int cursorPosition() const;
void setCursorPosition(int pos);
void setCursorVisible(bool visible);
void setCursorFromTextItem(ChatTextItemHelper *textItem, bool infront, int defaultPosition = 0);
QList<RichFormat::Format> formatsAtCursor(QTextCursor cursor = {}) const;
/**
* @brief Return the current cursor position of the underlying text item.
*/
std::optional<int> cursorPosition() const;
/**
* @brief Set the cursor position of the underlying text item to the given value.
*/
void setCursorPosition(int pos);
/**
* @brief Set the cursor visibility of the underlying text item to the given value.
*/
void setCursorVisible(bool visible);
/**
* @brief Set the cursor position to the same as the given text item.
*
* This will either be the first or last line dependent upon the infront value.
*/
void setCursorFromTextItem(ChatTextItemHelper *textItem, bool infront);
/**
* @brief Merge the given format on the given QTextCursor.
*/
void mergeFormatOnCursor(RichFormat::Format format, QTextCursor cursor = {});
/**
* @brief Whether the list can be indented more at the given cursor.
*/
bool canIndentListMoreAtCursor(QTextCursor cursor = {}) const;
/**
* @brief Whether the list can be indented less at the given cursor.
*/
bool canIndentListLessAtCursor(QTextCursor cursor = {}) const;
/**
* @brief Indented the list more at the given cursor.
*/
void indentListMoreAtCursor(QTextCursor cursor = {});
/**
* @brief Indented the list less at the given cursor.
*/
void indentListLessAtCursor(QTextCursor cursor = {});
/**
* @brief Force active focus on the underlying text item.
*/
void forceActiveFocus() const;
/**
* @brief Rehightlight the text in the text item.
*/
void rehighlight() const;
/**
* @brief Output the text in the text item in markdown format.
*/
QString markdownText() const;
Q_SIGNALS:
void textItemChanged();
void contentsChange(int position, int charsRemoved, int charsAdded);
void contentsChanged();
void cleared(ChatTextItemHelper *self);
void cursorPositionChanged();
void formatChanged();
void textFormatChanged();
void styleChanged();
void listChanged();
/**
* @brief Emitted when the contents of the underlying text item are changed.
*/
void contentsChange(int position, int charsRemoved, int charsAdded);
/**
* @brief Emitted when the contents of the underlying text item are changed.
*/
void contentsChanged();
/**
* @brief Emitted when the contents of the underlying text item are cleared.
*/
void cleared(ChatTextItemHelper *self);
/**
* @brief Emitted when the cursor position of the underlying text item is changed.
*/
void cursorPositionChanged(bool fromContentsChange);
private:
QPointer<QQuickItem> m_textItem;
QPointer<ChatBarSyntaxHighlighter> m_highlighter;
bool m_contentsJustChanged = false;
std::optional<Qt::TextFormat> textFormat() const;
QString m_fixedStartChars = {};
QString m_fixedEndChars = {};
QString m_initialText = {};
void initializeChars();
bool m_initializingChars = false;
bool isEmpty() const;
std::optional<int> lineLength(int lineNumber) const;

View File

@@ -71,20 +71,6 @@ QQC2.TextArea {
event.accepted = true;
Message.contentModel.keyHelper.down();
}
Keys.onLeftPressed: (event) => {
if (cursorPosition == 1) {
event.accepted = true;
} else {
event.accepted = false;
}
}
Keys.onRightPressed: (event) => {
if (cursorPosition == (length - 1)) {
event.accepted = true;
return;
}
event.accepted = false;
}
Keys.onDeletePressed: (event) => {
event.accepted = true;
@@ -123,12 +109,6 @@ QQC2.TextArea {
Message.contentModel.setFocusRow(root.index, true)
}
onCursorPositionChanged: if (cursorPosition == 0) {
cursorPosition = 1;
} else if (cursorPosition == length) {
cursorPosition = length - 1;
}
TapHandler {
enabled: !root.hoveredLink
acceptedDevices: PointerDevice.TouchScreen

View File

@@ -191,7 +191,7 @@ void ChatBarMessageContentModel::focusCurrentComponent(const QModelIndex &previo
return;
}
textItem->setCursorFromTextItem(textItemForIndex(previousIndex), down, MessageComponentType::Quote ? 1 : 0);
textItem->setCursorFromTextItem(textItemForIndex(previousIndex), down);
}
void ChatBarMessageContentModel::refocusCurrentComponent() const