Improved markdown support:

* Correctly displays inline code blocks with syntax highlighting turned on
as well as markdown at the same time
* Adds a context menu item for toggling markdown on and off which also
which essentially turns on/off all text processing
* Uses QTextDocument::MarkdownNoHTML to handle markdown in QTextDocument
which allows display of html tags like normal, but unfortunately does not
allow display of markdown tables as markdown

Signed-off-by: Adam Treat <treat.adam@gmail.com>
This commit is contained in:
Adam Treat 2024-06-28 11:10:20 -04:00 committed by AT
parent d92252cab1
commit 6f52f602ef
4 changed files with 161 additions and 48 deletions

View File

@ -109,6 +109,7 @@ qt_add_executable(chat
chatllm.h chatllm.cpp chatllm.h chatllm.cpp
chatmodel.h chatlistmodel.h chatlistmodel.cpp chatmodel.h chatlistmodel.h chatlistmodel.cpp
chatapi.h chatapi.cpp chatapi.h chatapi.cpp
chatviewtextprocessor.h chatviewtextprocessor.cpp
database.h database.cpp database.h database.cpp
download.h download.cpp download.h download.cpp
embllm.cpp embllm.h embllm.cpp embllm.h
@ -119,7 +120,6 @@ qt_add_executable(chat
network.h network.cpp network.h network.cpp
server.h server.cpp server.h server.cpp
logger.h logger.cpp logger.h logger.cpp
responsetext.h responsetext.cpp
${APP_ICON_RESOURCE} ${APP_ICON_RESOURCE}
${CHAT_EXE_RESOURCES} ${CHAT_EXE_RESOURCES}
) )

View File

@ -1,4 +1,4 @@
#include "responsetext.h" #include "chatviewtextprocessor.h"
#include <QBrush> #include <QBrush>
#include <QChar> #include <QChar>
@ -35,7 +35,8 @@ enum Language {
Csharp, Csharp,
Latex, Latex,
Html, Html,
Php Php,
Markdown
}; };
// TODO (Adam) These should be themeable and not hardcoded since they are quite harsh on the eyes in // TODO (Adam) These should be themeable and not hardcoded since they are quite harsh on the eyes in
@ -868,31 +869,32 @@ void SyntaxHighlighter::highlightBlock(const QString &text)
// not the replaced text. A possible solution is to have this class keep a mapping of the original // not the replaced text. A possible solution is to have this class keep a mapping of the original
// indices and the replacement indices and then use the original text that is stored in memory in the // indices and the replacement indices and then use the original text that is stored in memory in the
// chat class to populate the clipboard. // chat class to populate the clipboard.
ResponseText::ResponseText(QObject *parent) ChatViewTextProcessor::ChatViewTextProcessor(QObject *parent)
: QObject{parent} : QObject{parent}
, m_textDocument(nullptr) , m_textDocument(nullptr)
, m_syntaxHighlighter(new SyntaxHighlighter(this)) , m_syntaxHighlighter(new SyntaxHighlighter(this))
, m_isProcessingText(false) , m_isProcessingText(false)
, m_shouldProcessText(true)
{ {
} }
QQuickTextDocument* ResponseText::textDocument() const QQuickTextDocument* ChatViewTextProcessor::textDocument() const
{ {
return m_textDocument; return m_textDocument;
} }
void ResponseText::setTextDocument(QQuickTextDocument* textDocument) void ChatViewTextProcessor::setTextDocument(QQuickTextDocument* textDocument)
{ {
if (m_textDocument) if (m_textDocument)
disconnect(m_textDocument->textDocument(), &QTextDocument::contentsChanged, this, &ResponseText::handleTextChanged); disconnect(m_textDocument->textDocument(), &QTextDocument::contentsChanged, this, &ChatViewTextProcessor::handleTextChanged);
m_textDocument = textDocument; m_textDocument = textDocument;
m_syntaxHighlighter->setDocument(m_textDocument->textDocument()); m_syntaxHighlighter->setDocument(m_textDocument->textDocument());
connect(m_textDocument->textDocument(), &QTextDocument::contentsChanged, this, &ResponseText::handleTextChanged); connect(m_textDocument->textDocument(), &QTextDocument::contentsChanged, this, &ChatViewTextProcessor::handleTextChanged);
handleTextChanged(); handleTextChanged();
} }
bool ResponseText::tryCopyAtPosition(int position) const bool ChatViewTextProcessor::tryCopyAtPosition(int position) const
{ {
for (const auto &copy : m_copies) { for (const auto &copy : m_copies) {
if (position >= copy.startPos && position <= copy.endPos) { if (position >= copy.startPos && position <= copy.endPos) {
@ -904,9 +906,67 @@ bool ResponseText::tryCopyAtPosition(int position) const
return false; return false;
} }
void ResponseText::handleTextChanged() bool ChatViewTextProcessor::shouldProcessText() const
{ {
if (!m_textDocument || m_isProcessingText) return m_shouldProcessText;
}
void ChatViewTextProcessor::setShouldProcessText(bool b)
{
if (m_shouldProcessText == b)
return;
m_shouldProcessText = b;
emit shouldProcessTextChanged();
handleTextChanged();
}
void traverseDocument(QTextDocument *doc)
{
QTextFrame *rootFrame = doc->rootFrame();
QTextFrame::iterator rootIt;
for (rootIt = rootFrame->begin(); !rootIt.atEnd(); ++rootIt) {
QTextFrame *childFrame = rootIt.currentFrame();
QTextBlock childBlock = rootIt.currentBlock();
if (childFrame) {
qDebug() << "Frame from" << childFrame->firstPosition() << "to" << childFrame->lastPosition();
// Iterate over blocks within the frame
QTextFrame::iterator frameIt;
for (frameIt = childFrame->begin(); !frameIt.atEnd(); ++frameIt) {
QTextBlock block = frameIt.currentBlock();
if (block.isValid()) {
qDebug() << " Block position:" << block.position();
qDebug() << " Block text:" << block.text();
// Iterate over lines within the block
for (QTextBlock::iterator blockIt = block.begin(); !(blockIt.atEnd()); ++blockIt) {
QTextFragment fragment = blockIt.fragment();
if (fragment.isValid()) {
qDebug() << " Fragment text:" << fragment.text();
}
}
}
}
} else if (childBlock.isValid()) {
qDebug() << "Block position:" << childBlock.position();
qDebug() << "Block text:" << childBlock.text();
// Iterate over lines within the block
for (QTextBlock::iterator blockIt = childBlock.begin(); !(blockIt.atEnd()); ++blockIt) {
QTextFragment fragment = blockIt.fragment();
if (fragment.isValid()) {
qDebug() << " Fragment text:" << fragment.text();
}
}
}
}
}
void ChatViewTextProcessor::handleTextChanged()
{
if (!m_textDocument || m_isProcessingText || !m_shouldProcessText)
return; return;
m_isProcessingText = true; m_isProcessingText = true;
@ -917,6 +977,7 @@ void ResponseText::handleTextChanged()
(void)doc->documentLayout()->documentSize(); (void)doc->documentLayout()->documentSize();
handleCodeBlocks(); handleCodeBlocks();
handleMarkdown();
// We insert an invisible char at the end to make sure the document goes back to the default // We insert an invisible char at the end to make sure the document goes back to the default
// text format // text format
@ -926,18 +987,7 @@ void ResponseText::handleTextChanged()
m_isProcessingText = false; m_isProcessingText = false;
} }
void replaceAndInsertMarkdown(int startIndex, int endIndex, QTextDocument *doc) void ChatViewTextProcessor::handleCodeBlocks()
{
QTextCursor cursor(doc);
cursor.setPosition(startIndex);
cursor.setPosition(endIndex, QTextCursor::KeepAnchor);
QTextDocumentFragment fragment(cursor);
const QString plainText = fragment.toPlainText();
cursor.removeSelectedText();
cursor.insertMarkdown(plainText);
}
void ResponseText::handleCodeBlocks()
{ {
QTextDocument* doc = m_textDocument->textDocument(); QTextDocument* doc = m_textDocument->textDocument();
QTextCursor cursor(doc); QTextCursor cursor(doc);
@ -997,21 +1047,15 @@ void ResponseText::handleCodeBlocks()
matchesCode.append(iCode.next()); matchesCode.append(iCode.next());
QVector<CodeCopy> newCopies; QVector<CodeCopy> newCopies;
QVector<QTextFrame*> frames;
// Track the position in the document to handle non-code blocks
int lastIndex = 0;
for(int index = matchesCode.count() - 1; index >= 0; --index) { for(int index = matchesCode.count() - 1; index >= 0; --index) {
int nonCodeStart = lastIndex;
int nonCodeEnd = matchesCode[index].capturedStart();
if (nonCodeEnd > nonCodeStart)
replaceAndInsertMarkdown(nonCodeStart, nonCodeEnd, doc);
cursor.setPosition(matchesCode[index].capturedStart()); cursor.setPosition(matchesCode[index].capturedStart());
cursor.setPosition(matchesCode[index].capturedEnd(), QTextCursor::KeepAnchor); cursor.setPosition(matchesCode[index].capturedEnd(), QTextCursor::KeepAnchor);
cursor.removeSelectedText(); cursor.removeSelectedText();
int startPos = cursor.position();
QTextFrameFormat frameFormat = frameFormatBase; QTextFrameFormat frameFormat = frameFormatBase;
QString capturedText = matchesCode[index].captured(1); QString capturedText = matchesCode[index].captured(1);
QString codeLanguage; QString codeLanguage;
@ -1100,12 +1144,66 @@ void ResponseText::handleCodeBlocks()
cursor = mainFrame->lastCursorPosition(); cursor = mainFrame->lastCursorPosition();
cursor.setCharFormat(QTextCharFormat()); cursor.setCharFormat(QTextCharFormat());
lastIndex = matchesCode[index].capturedEnd();
} }
if (lastIndex < doc->characterCount())
replaceAndInsertMarkdown(lastIndex, doc->characterCount() - 1, doc);
m_copies = newCopies; m_copies = newCopies;
} }
void replaceAndInsertMarkdown(int startIndex, int endIndex, QTextDocument *doc)
{
QTextCursor cursor(doc);
cursor.setPosition(startIndex);
cursor.setPosition(endIndex, QTextCursor::KeepAnchor);
QTextDocumentFragment fragment(cursor);
const QString plainText = fragment.toPlainText();
cursor.removeSelectedText();
cursor.insertMarkdown(plainText, QTextDocument::MarkdownNoHTML);
cursor.block().setUserState(Markdown);
}
void ChatViewTextProcessor::handleMarkdown()
{
QTextDocument* doc = m_textDocument->textDocument();
QTextCursor cursor(doc);
QVector<QPair<int, int>> codeBlockPositions;
QTextFrame *rootFrame = doc->rootFrame();
QTextFrame::iterator rootIt;
bool hasAlreadyProcessedMarkdown = false;
for (rootIt = rootFrame->begin(); !rootIt.atEnd(); ++rootIt) {
QTextFrame *childFrame = rootIt.currentFrame();
QTextBlock childBlock = rootIt.currentBlock();
if (childFrame) {
codeBlockPositions.append(qMakePair(childFrame->firstPosition()-1, childFrame->lastPosition()+1));
for (QTextFrame::iterator frameIt = childFrame->begin(); !frameIt.atEnd(); ++frameIt) {
QTextBlock block = frameIt.currentBlock();
if (block.isValid() && block.userState() == Markdown)
hasAlreadyProcessedMarkdown = true;
}
} else if (childBlock.isValid() && childBlock.userState() == Markdown)
hasAlreadyProcessedMarkdown = true;
}
if (!hasAlreadyProcessedMarkdown) {
std::sort(codeBlockPositions.begin(), codeBlockPositions.end(), [](const QPair<int, int> &a, const QPair<int, int> &b) {
return a.first > b.first;
});
int lastIndex = doc->characterCount() - 1;
for (const auto &pos : codeBlockPositions) {
int nonCodeStart = pos.second;
int nonCodeEnd = lastIndex;
if (nonCodeEnd > nonCodeStart) {
replaceAndInsertMarkdown(nonCodeStart, nonCodeEnd, doc);
}
lastIndex = pos.first;
}
if (lastIndex > 0)
replaceAndInsertMarkdown(0, lastIndex, doc);
}
}

View File

@ -1,5 +1,5 @@
#ifndef RESPONSETEXT_H #ifndef CHATVIEWTEXTPROCESSOR_H
#define RESPONSETEXT_H #define CHATVIEWTEXTPROCESSOR_H
#include <QColor> #include <QColor>
#include <QObject> #include <QObject>
@ -37,13 +37,14 @@ struct CodeCopy {
QString text; QString text;
}; };
class ResponseText : public QObject class ChatViewTextProcessor : public QObject
{ {
Q_OBJECT Q_OBJECT
Q_PROPERTY(QQuickTextDocument* textDocument READ textDocument WRITE setTextDocument NOTIFY textDocumentChanged()) Q_PROPERTY(QQuickTextDocument* textDocument READ textDocument WRITE setTextDocument NOTIFY textDocumentChanged())
Q_PROPERTY(bool shouldProcessText READ shouldProcessText WRITE setShouldProcessText NOTIFY shouldProcessTextChanged())
QML_ELEMENT QML_ELEMENT
public: public:
explicit ResponseText(QObject *parent = nullptr); explicit ChatViewTextProcessor(QObject *parent = nullptr);
QQuickTextDocument* textDocument() const; QQuickTextDocument* textDocument() const;
void setTextDocument(QQuickTextDocument* textDocument); void setTextDocument(QQuickTextDocument* textDocument);
@ -53,12 +54,17 @@ public:
Q_INVOKABLE bool tryCopyAtPosition(int position) const; Q_INVOKABLE bool tryCopyAtPosition(int position) const;
bool shouldProcessText() const;
void setShouldProcessText(bool b);
Q_SIGNALS: Q_SIGNALS:
void textDocumentChanged(); void textDocumentChanged();
void shouldProcessTextChanged();
private Q_SLOTS: private Q_SLOTS:
void handleTextChanged(); void handleTextChanged();
void handleCodeBlocks(); void handleCodeBlocks();
void handleMarkdown();
private: private:
QQuickTextDocument *m_textDocument; QQuickTextDocument *m_textDocument;
@ -67,7 +73,8 @@ private:
QVector<CodeCopy> m_copies; QVector<CodeCopy> m_copies;
QColor m_linkColor; QColor m_linkColor;
QColor m_headerColor; QColor m_headerColor;
bool m_shouldProcessText = false;
bool m_isProcessingText = false; bool m_isProcessingText = false;
}; };
#endif // RESPONSETEXT_H #endif // CHATVIEWTEXTPROCESSOR_H

View File

@ -824,7 +824,7 @@ Rectangle {
id: tapHandler id: tapHandler
onTapped: function(eventPoint, button) { onTapped: function(eventPoint, button) {
var clickedPos = myTextArea.positionAt(eventPoint.position.x, eventPoint.position.y); var clickedPos = myTextArea.positionAt(eventPoint.position.x, eventPoint.position.y);
var success = responseText.tryCopyAtPosition(clickedPos); var success = textProcessor.tryCopyAtPosition(clickedPos);
if (success) if (success)
copyCodeMessage.open(); copyCodeMessage.open();
} }
@ -862,16 +862,24 @@ Rectangle {
myTextArea.deselect() myTextArea.deselect()
} }
} }
MenuItem {
text: textProcessor.shouldProcessText ? qsTr("Disable markdown") : qsTr("Enable markdown")
height: enabled ? implicitHeight : 0
onTriggered: {
textProcessor.shouldProcessText = !textProcessor.shouldProcessText;
myTextArea.text = value
}
}
} }
ResponseText { ChatViewTextProcessor {
id: responseText id: textProcessor
} }
Component.onCompleted: { Component.onCompleted: {
responseText.setLinkColor(theme.linkColor); textProcessor.setLinkColor(theme.linkColor);
responseText.setHeaderColor(name === qsTr("Response: ") ? theme.darkContrast : theme.lightContrast); textProcessor.setHeaderColor(name === qsTr("Response: ") ? theme.darkContrast : theme.lightContrast);
responseText.textDocument = textDocument textProcessor.textDocument = textDocument
} }
Accessible.role: Accessible.Paragraph Accessible.role: Accessible.Paragraph