From 318c51c14123bbdc955894d1e085e76f66012238 Mon Sep 17 00:00:00 2001 From: Adam Treat Date: Sat, 10 Jun 2023 12:34:43 -0400 Subject: [PATCH] Add code blocks and python syntax highlighting. --- gpt4all-chat/main.qml | 11 ++ gpt4all-chat/responsetext.cpp | 267 ++++++++++++++++++++++++++++++++-- gpt4all-chat/responsetext.h | 23 +-- 3 files changed, 277 insertions(+), 24 deletions(-) diff --git a/gpt4all-chat/main.qml b/gpt4all-chat/main.qml index 42fa0f79..13ed2ba3 100644 --- a/gpt4all-chat/main.qml +++ b/gpt4all-chat/main.qml @@ -378,6 +378,12 @@ Window { text: qsTr("Conversation copied to clipboard.") } + PopupDialog { + id: copyCodeMessage + anchors.centerIn: parent + text: qsTr("Code copied to clipboard.") + } + PopupDialog { id: healthCheckFailed anchors.centerIn: parent @@ -607,6 +613,10 @@ Window { var integer = parseInt(link.split("://")[1]); referenceContextDialog.text = referencesContext[integer - 1]; referenceContextDialog.open(); + } else { + var success = responseText.tryCopyAtPosition(clickedPos); + if (success) + copyCodeMessage.open(); } } } @@ -618,6 +628,7 @@ Window { Component.onCompleted: { responseText.textDocument = textDocument responseText.setLinkColor(theme.linkColor); + responseText.setHeaderColor(theme.backgroundLight); } Accessible.role: Accessible.Paragraph diff --git a/gpt4all-chat/responsetext.cpp b/gpt4all-chat/responsetext.cpp index aba3727a..edf4f466 100644 --- a/gpt4all-chat/responsetext.cpp +++ b/gpt4all-chat/responsetext.cpp @@ -5,6 +5,92 @@ #include #include #include +#include +#include + +enum Language { + None, + Python +}; + +static QColor keywordColor = "#2e95d3"; // blue +static QColor functionColor = "#f22c3d"; // red +static QColor functionCallColor = "#e9950c"; // orange +static QColor commentColor = "#808080"; // gray +static QColor stringColor = "#00a37d"; // green +static QColor numberColor = "#df3079"; // fuschia + +static Language stringToLanguage(const QString &language) +{ + if (language == "python") + return Python; + return None; +} + +struct HighlightingRule { + QRegularExpression pattern; + QTextCharFormat format; +}; + +static QVector pythonHighlightingRules() +{ + static QVector highlightingRules; + if (highlightingRules.isEmpty()) { + + HighlightingRule rule; + + QTextCharFormat functionCallFormat; + functionCallFormat.setForeground(functionCallColor); + rule.pattern = QRegularExpression("\\b(\\w+)\\s*(?=\\()"); + rule.format = functionCallFormat; + highlightingRules.append(rule); + + QTextCharFormat functionFormat; + functionFormat.setForeground(functionColor); + rule.pattern = QRegularExpression("\\bdef\\s+(\\w+)\\b"); + rule.format = functionFormat; + highlightingRules.append(rule); + + QTextCharFormat numberFormat; + numberFormat.setForeground(numberColor); + rule.pattern = QRegularExpression("\\b[0-9]*\\.?[0-9]+\\b"); + rule.format = numberFormat; + highlightingRules.append(rule); + + QTextCharFormat keywordFormat; + keywordFormat.setForeground(keywordColor); + QStringList keywordPatterns = { + "\\bdef\\b", "\\bclass\\b", "\\bif\\b", "\\belse\\b", "\\belif\\b", + "\\bwhile\\b", "\\bfor\\b", "\\breturn\\b", "\\bprint\\b", "\\bimport\\b", + "\\bfrom\\b", "\\bas\\b", "\\btry\\b", "\\bexcept\\b", "\\braise\\b", + "\\bwith\\b", "\\bfinally\\b", "\\bcontinue\\b", "\\bbreak\\b", "\\bpass\\b" + }; + + for (const QString &pattern : keywordPatterns) { + rule.pattern = QRegularExpression(pattern); + rule.format = keywordFormat; + highlightingRules.append(rule); + } + + QTextCharFormat stringFormat; + stringFormat.setForeground(stringColor); + rule.pattern = QRegularExpression("\".*?\""); + rule.format = stringFormat; + highlightingRules.append(rule); + + rule.pattern = QRegularExpression("\'.*?\'"); + rule.format = stringFormat; + highlightingRules.append(rule); + + QTextCharFormat commentFormat; + commentFormat.setForeground(commentColor); + rule.pattern = QRegularExpression("#[^\n]*"); + rule.format = commentFormat; + highlightingRules.append(rule); + + } + return highlightingRules; +} SyntaxHighlighter::SyntaxHighlighter(QObject *parent) : QSyntaxHighlighter(parent) @@ -17,11 +103,19 @@ SyntaxHighlighter::~SyntaxHighlighter() void SyntaxHighlighter::highlightBlock(const QString &text) { - for (const HighlightingRule &rule : qAsConst(m_highlightingRules)) { + QTextBlock block = this->currentBlock(); + + QVector rules; + if (block.userState() == Python) + rules = pythonHighlightingRules(); + + for (const HighlightingRule &rule : qAsConst(rules)) { QRegularExpressionMatchIterator matchIterator = rule.pattern.globalMatch(text); while (matchIterator.hasNext()) { QRegularExpressionMatch match = matchIterator.next(); - setFormat(match.capturedStart(), match.capturedLength(), rule.format); + int startIndex = match.capturedStart(); + int length = match.capturedLength(); + setFormat(startIndex, length, rule.format); } } } @@ -59,6 +153,18 @@ QString ResponseText::getLinkAtPosition(int position) const return QString(); } +bool ResponseText::tryCopyAtPosition(int position) const +{ + for (const auto © : m_copies) { + if (position >= copy.startPos && position < copy.endPos) { + QClipboard *clipboard = QGuiApplication::clipboard(); + clipboard->setText(copy.text); + return true; + } + } + return false; +} + void ResponseText::handleTextChanged() { if (!m_textDocument || m_isProcessingText) @@ -66,25 +172,32 @@ void ResponseText::handleTextChanged() m_isProcessingText = true; QTextDocument* doc = m_textDocument->textDocument(); - QTextCursor cursor(doc); + handleContextLinks(); + handleCodeBlocks(); + m_isProcessingText = false; +} +void ResponseText::handleContextLinks() +{ + QTextDocument* doc = m_textDocument->textDocument(); + QTextCursor cursor(doc); QTextCharFormat linkFormat; linkFormat.setForeground(m_linkColor); linkFormat.setFontUnderline(true); - // Loop through the document looking for context links - QRegularExpression re("\\[Context\\]\\((context://\\d+)\\)"); - QRegularExpressionMatchIterator i = re.globalMatch(doc->toPlainText()); + // Regex for context links + QRegularExpression reLink("\\[Context\\]\\((context://\\d+)\\)"); + QRegularExpressionMatchIterator iLink = reLink.globalMatch(doc->toPlainText()); - QList matches; - while (i.hasNext()) - matches.append(i.next()); + QList matchesLink; + while (iLink.hasNext()) + matchesLink.append(iLink.next()); QVector newLinks; // Calculate new positions and store them in newLinks int positionOffset = 0; - for(const auto &match : matches) { + for(const auto &match : matchesLink) { ContextLink newLink; newLink.href = match.captured(1); newLink.text = "Context"; @@ -95,9 +208,9 @@ void ResponseText::handleTextChanged() } // Replace the context links with the word "Context" in reverse order - for(int index = matches.count() - 1; index >= 0; --index) { - cursor.setPosition(matches.at(index).capturedStart()); - cursor.setPosition(matches.at(index).capturedEnd(), QTextCursor::KeepAnchor); + for(int index = matchesLink.count() - 1; index >= 0; --index) { + cursor.setPosition(matchesLink.at(index).capturedStart()); + cursor.setPosition(matchesLink.at(index).capturedEnd(), QTextCursor::KeepAnchor); cursor.removeSelectedText(); cursor.setCharFormat(linkFormat); cursor.insertText(newLinks.at(index).text); @@ -105,5 +218,131 @@ void ResponseText::handleTextChanged() } m_links = newLinks; - m_isProcessingText = false; +} + +void ResponseText::handleCodeBlocks() +{ + QTextDocument* doc = m_textDocument->textDocument(); + QTextCursor cursor(doc); + + QTextCharFormat textFormat; + textFormat.setFontFamilies(QStringList() << "Monospace"); + textFormat.setForeground(QColor("white")); + + QTextFrameFormat frameFormatBase; + frameFormatBase.setBackground(QColor("black")); + + QTextTableFormat tableFormat; + tableFormat.setMargin(0); + tableFormat.setPadding(0); + tableFormat.setBorder(0); + tableFormat.setBorderCollapse(true); + QList constraints; + constraints << QTextLength(QTextLength::PercentageLength, 100); + tableFormat.setColumnWidthConstraints(constraints); + + QTextTableFormat headerTableFormat; + headerTableFormat.setBackground(m_headerColor); + headerTableFormat.setPadding(0); + headerTableFormat.setBorder(0); + headerTableFormat.setBorderCollapse(true); + headerTableFormat.setTopMargin(15); + headerTableFormat.setBottomMargin(15); + headerTableFormat.setLeftMargin(30); + headerTableFormat.setRightMargin(30); + QList headerConstraints; + headerConstraints << QTextLength(QTextLength::PercentageLength, 80); + headerConstraints << QTextLength(QTextLength::PercentageLength, 20); + headerTableFormat.setColumnWidthConstraints(headerConstraints); + + QTextTableFormat codeBlockTableFormat; + codeBlockTableFormat.setBackground(QColor("black")); + codeBlockTableFormat.setPadding(0); + codeBlockTableFormat.setBorder(0); + codeBlockTableFormat.setBorderCollapse(true); + codeBlockTableFormat.setTopMargin(30); + codeBlockTableFormat.setBottomMargin(30); + codeBlockTableFormat.setLeftMargin(30); + codeBlockTableFormat.setRightMargin(30); + codeBlockTableFormat.setColumnWidthConstraints(constraints); + + QTextImageFormat copyImageFormat; + copyImageFormat.setWidth(30); + copyImageFormat.setHeight(30); + copyImageFormat.setName("qrc:/gpt4all/icons/copy.svg"); + + // Regex for code blocks + QRegularExpression reCode("```(.*?)(```|$)", QRegularExpression::DotMatchesEverythingOption); + QRegularExpressionMatchIterator iCode = reCode.globalMatch(doc->toPlainText()); + + QList matchesCode; + while (iCode.hasNext()) + matchesCode.append(iCode.next()); + + QVector newCopies; + + for(int index = matchesCode.count() - 1; index >= 0; --index) { + cursor.setPosition(matchesCode[index].capturedStart()); + cursor.setPosition(matchesCode[index].capturedEnd(), QTextCursor::KeepAnchor); + cursor.removeSelectedText(); + + // Check if the first word in the code block is "python" + QTextFrameFormat frameFormat = frameFormatBase; + QStringList lines = matchesCode[index].captured(1).split('\n'); + QString codeLanguage; + if (!lines.empty() && lines[0].trimmed() == "python") + codeLanguage = lines.takeFirst().trimmed(); +\ + QTextFrame *mainFrame = cursor.currentFrame(); + cursor.setCharFormat(textFormat); + + QTextFrame *frame = cursor.insertFrame(frameFormat); + QTextTable *table = cursor.insertTable(codeLanguage.isEmpty() ? 1 : 2, 1, tableFormat); + + if (!codeLanguage.isEmpty()) { + QTextTableCell headerCell = table->cellAt(0, 0); + QTextCursor headerCellCursor = headerCell.firstCursorPosition(); + QTextTable *headerTable = headerCellCursor.insertTable(1, 2, headerTableFormat); + QTextTableCell header = headerTable->cellAt(0, 0); + QTextCursor headerCursor = header.firstCursorPosition(); + headerCursor.insertText(codeLanguage); + QTextTableCell copy = headerTable->cellAt(0, 1); + QTextCursor copyCursor = copy.firstCursorPosition(); + int startPos = copyCursor.position(); + CodeCopy newCopy; + newCopy.text = lines.join("\n"); + newCopy.startPos = copyCursor.position(); + newCopy.endPos = newCopy.startPos + 1; + newCopies.append(newCopy); + QTextBlockFormat blockFormat; + blockFormat.setAlignment(Qt::AlignRight); + copyCursor.setBlockFormat(blockFormat); + copyCursor.insertImage(copyImageFormat, QTextFrameFormat::FloatRight); + } + + QTextTableCell codeCell = table->cellAt(codeLanguage.isEmpty() ? 0 : 1, 0); + QTextCursor codeCellCursor = codeCell.firstCursorPosition(); + QTextTable *codeTable = codeCellCursor.insertTable(1, 1, codeBlockTableFormat); + QTextTableCell code = codeTable->cellAt(0, 0); + QTextCursor codeCursor = code.firstCursorPosition(); + if (!codeLanguage.isEmpty()) { + codeCursor.block().setUserState(stringToLanguage(codeLanguage)); + for (const QString &line : lines) { + codeCursor.insertText(line); + codeCursor.insertBlock(); + codeCursor.block().setUserState(stringToLanguage(codeLanguage)); + } + } else { + codeCursor.insertText(lines.join("\n")); + } + if (codeCursor.position() > 0) { + codeCursor.setPosition(codeCursor.position() - 1); + codeCursor.deleteChar(); + } + + cursor = mainFrame->lastCursorPosition(); + cursor.setCharFormat(QTextCharFormat()); + } + + m_copies = newCopies; } diff --git a/gpt4all-chat/responsetext.h b/gpt4all-chat/responsetext.h index 90537aa1..a5588bd6 100644 --- a/gpt4all-chat/responsetext.h +++ b/gpt4all-chat/responsetext.h @@ -7,22 +7,12 @@ #include #include -struct HighlightingRule -{ - QRegularExpression pattern; - QTextCharFormat format; -}; - class SyntaxHighlighter : public QSyntaxHighlighter { Q_OBJECT public: SyntaxHighlighter(QObject *parent); ~SyntaxHighlighter(); - void highlightBlock(const QString &text) override; - -private: - QVector m_highlightingRules; }; struct ContextLink { @@ -32,6 +22,12 @@ struct ContextLink { QString href; }; +struct CodeCopy { + int startPos = -1; + int endPos = -1; + QString text; +}; + class ResponseText : public QObject { Q_OBJECT @@ -44,19 +40,26 @@ public: void setTextDocument(QQuickTextDocument* textDocument); Q_INVOKABLE void setLinkColor(const QColor &c) { m_linkColor = c; } + Q_INVOKABLE void setHeaderColor(const QColor &c) { m_headerColor = c; } + Q_INVOKABLE QString getLinkAtPosition(int position) const; + Q_INVOKABLE bool tryCopyAtPosition(int position) const; Q_SIGNALS: void textDocumentChanged(); private Q_SLOTS: void handleTextChanged(); + void handleContextLinks(); + void handleCodeBlocks(); private: QQuickTextDocument *m_textDocument; SyntaxHighlighter *m_syntaxHighlighter; QVector m_links; + QVector m_copies; QColor m_linkColor; + QColor m_headerColor; bool m_isProcessingText = false; };