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
chatmodel.h chatlistmodel.h chatlistmodel.cpp
chatapi.h chatapi.cpp
chatviewtextprocessor.h chatviewtextprocessor.cpp
database.h database.cpp
download.h download.cpp
embllm.cpp embllm.h
@ -119,7 +120,6 @@ qt_add_executable(chat
network.h network.cpp
server.h server.cpp
logger.h logger.cpp
responsetext.h responsetext.cpp
${APP_ICON_RESOURCE}
${CHAT_EXE_RESOURCES}
)

View File

@ -1,4 +1,4 @@
#include "responsetext.h"
#include "chatviewtextprocessor.h"
#include <QBrush>
#include <QChar>
@ -35,7 +35,8 @@ enum Language {
Csharp,
Latex,
Html,
Php
Php,
Markdown
};
// 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
// indices and the replacement indices and then use the original text that is stored in memory in the
// chat class to populate the clipboard.
ResponseText::ResponseText(QObject *parent)
ChatViewTextProcessor::ChatViewTextProcessor(QObject *parent)
: QObject{parent}
, m_textDocument(nullptr)
, m_syntaxHighlighter(new SyntaxHighlighter(this))
, m_isProcessingText(false)
, m_shouldProcessText(true)
{
}
QQuickTextDocument* ResponseText::textDocument() const
QQuickTextDocument* ChatViewTextProcessor::textDocument() const
{
return m_textDocument;
}
void ResponseText::setTextDocument(QQuickTextDocument* textDocument)
void ChatViewTextProcessor::setTextDocument(QQuickTextDocument* 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_syntaxHighlighter->setDocument(m_textDocument->textDocument());
connect(m_textDocument->textDocument(), &QTextDocument::contentsChanged, this, &ResponseText::handleTextChanged);
connect(m_textDocument->textDocument(), &QTextDocument::contentsChanged, this, &ChatViewTextProcessor::handleTextChanged);
handleTextChanged();
}
bool ResponseText::tryCopyAtPosition(int position) const
bool ChatViewTextProcessor::tryCopyAtPosition(int position) const
{
for (const auto &copy : m_copies) {
if (position >= copy.startPos && position <= copy.endPos) {
@ -904,9 +906,67 @@ bool ResponseText::tryCopyAtPosition(int position) const
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;
m_isProcessingText = true;
@ -917,6 +977,7 @@ void ResponseText::handleTextChanged()
(void)doc->documentLayout()->documentSize();
handleCodeBlocks();
handleMarkdown();
// We insert an invisible char at the end to make sure the document goes back to the default
// text format
@ -926,18 +987,7 @@ void ResponseText::handleTextChanged()
m_isProcessingText = false;
}
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);
}
void ResponseText::handleCodeBlocks()
void ChatViewTextProcessor::handleCodeBlocks()
{
QTextDocument* doc = m_textDocument->textDocument();
QTextCursor cursor(doc);
@ -997,21 +1047,15 @@ void ResponseText::handleCodeBlocks()
matchesCode.append(iCode.next());
QVector<CodeCopy> newCopies;
// Track the position in the document to handle non-code blocks
int lastIndex = 0;
QVector<QTextFrame*> frames;
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].capturedEnd(), QTextCursor::KeepAnchor);
cursor.removeSelectedText();
int startPos = cursor.position();
QTextFrameFormat frameFormat = frameFormatBase;
QString capturedText = matchesCode[index].captured(1);
QString codeLanguage;
@ -1100,12 +1144,66 @@ void ResponseText::handleCodeBlocks()
cursor = mainFrame->lastCursorPosition();
cursor.setCharFormat(QTextCharFormat());
lastIndex = matchesCode[index].capturedEnd();
}
if (lastIndex < doc->characterCount())
replaceAndInsertMarkdown(lastIndex, doc->characterCount() - 1, doc);
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
#define RESPONSETEXT_H
#ifndef CHATVIEWTEXTPROCESSOR_H
#define CHATVIEWTEXTPROCESSOR_H
#include <QColor>
#include <QObject>
@ -37,13 +37,14 @@ struct CodeCopy {
QString text;
};
class ResponseText : public QObject
class ChatViewTextProcessor : public QObject
{
Q_OBJECT
Q_PROPERTY(QQuickTextDocument* textDocument READ textDocument WRITE setTextDocument NOTIFY textDocumentChanged())
Q_PROPERTY(bool shouldProcessText READ shouldProcessText WRITE setShouldProcessText NOTIFY shouldProcessTextChanged())
QML_ELEMENT
public:
explicit ResponseText(QObject *parent = nullptr);
explicit ChatViewTextProcessor(QObject *parent = nullptr);
QQuickTextDocument* textDocument() const;
void setTextDocument(QQuickTextDocument* textDocument);
@ -53,12 +54,17 @@ public:
Q_INVOKABLE bool tryCopyAtPosition(int position) const;
bool shouldProcessText() const;
void setShouldProcessText(bool b);
Q_SIGNALS:
void textDocumentChanged();
void shouldProcessTextChanged();
private Q_SLOTS:
void handleTextChanged();
void handleCodeBlocks();
void handleMarkdown();
private:
QQuickTextDocument *m_textDocument;
@ -67,7 +73,8 @@ private:
QVector<CodeCopy> m_copies;
QColor m_linkColor;
QColor m_headerColor;
bool m_shouldProcessText = false;
bool m_isProcessingText = false;
};
#endif // RESPONSETEXT_H
#endif // CHATVIEWTEXTPROCESSOR_H

View File

@ -824,7 +824,7 @@ Rectangle {
id: tapHandler
onTapped: function(eventPoint, button) {
var clickedPos = myTextArea.positionAt(eventPoint.position.x, eventPoint.position.y);
var success = responseText.tryCopyAtPosition(clickedPos);
var success = textProcessor.tryCopyAtPosition(clickedPos);
if (success)
copyCodeMessage.open();
}
@ -862,16 +862,24 @@ Rectangle {
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 {
id: responseText
ChatViewTextProcessor {
id: textProcessor
}
Component.onCompleted: {
responseText.setLinkColor(theme.linkColor);
responseText.setHeaderColor(name === qsTr("Response: ") ? theme.darkContrast : theme.lightContrast);
responseText.textDocument = textDocument
textProcessor.setLinkColor(theme.linkColor);
textProcessor.setHeaderColor(name === qsTr("Response: ") ? theme.darkContrast : theme.lightContrast);
textProcessor.textDocument = textDocument
}
Accessible.role: Accessible.Paragraph