Add desktop message markup parser and syntax highlighting preparation
This commit is contained in:
1 parent
5a8a50e74c
commit
3a6844fc9d
3 files changed
+265
-2
No files matched your search
@@ -33,6 +33,17 @@ else()
|
||||
set(QT_PACKAGE Qt6)
|
||||
endif()
|
||||
|
||||
if(QT_PACKAGE STREQUAL "Qt6")
|
||||
find_package(KF6SyntaxHighlighting REQUIRED)
|
||||
set(CHAT_SYNTAX_LIBRARY KF6::SyntaxHighlighting)
|
||||
else()
|
||||
find_package(KF5SyntaxHighlighting REQUIRED)
|
||||
set(CHAT_SYNTAX_LIBRARY KF5::SyntaxHighlighting)
|
||||
endif()
|
||||
|
||||
add_library(chatgui_message_text STATIC src/message_text.cpp src/message_links.cpp)
|
||||
target_link_libraries(chatgui_message_text PUBLIC ${QT_LIBS} PRIVATE ${CHAT_SYNTAX_LIBRARY} utun)
|
||||
|
||||
# Каталоги в ресурсах: перевод работает и в переносимой сборке без файлов рядом с exe.
|
||||
file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/chatgui_qt_package.txt" "${QT_PACKAGE}\n")
|
||||
get_target_property(QT_QMAKE ${QT_PACKAGE}::qmake IMPORTED_LOCATION)
|
||||
@@ -319,7 +330,6 @@ add_executable(vibechat WIN32
|
||||
src/channelsettingsdialog.cpp
|
||||
src/chatview.cpp
|
||||
src/messagedelegate.cpp
|
||||
src/message_links.cpp
|
||||
src/messagelist.cpp
|
||||
src/attachment_key_ui.cpp
|
||||
src/messagefeedback.cpp
|
||||
@@ -762,7 +772,7 @@ target_link_libraries(test_emoji_playback PRIVATE ${QT_LIBS} ${QT_TEST_LIB} rlot
|
||||
add_test(NAME test_emoji_playback COMMAND test_emoji_playback)
|
||||
set_tests_properties(test_emoji_playback PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 30)
|
||||
|
||||
add_executable(test_invite_links tests/test_invite_links.cpp src/message_links.cpp src/messagedelegate.cpp src/chatview.cpp
|
||||
add_executable(test_invite_links tests/test_invite_links.cpp src/messagedelegate.cpp src/chatview.cpp
|
||||
src/accountdelegate.cpp src/flagpainter.cpp src/animtimer.cpp src/lottieicon.cpp src/emoji.cpp
|
||||
src/voiceplayback.cpp src/sound_manager.cpp src/voice_audio_io.cpp src/audio_device.cpp
|
||||
transport/miniaudio_impl.c transport/audio_diagnostics.cpp)
|
||||
@@ -771,6 +781,10 @@ target_link_libraries(test_invite_links PRIVATE ${QT_LIBS} ${QT_TEST_LIB} rlotti
|
||||
add_test(NAME test_invite_links COMMAND test_invite_links)
|
||||
set_tests_properties(test_invite_links PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 30)
|
||||
|
||||
foreach(message_target vibechat test_file_drop test_emoji_playback test_invite_links)
|
||||
target_link_libraries(${message_target} PRIVATE chatgui_message_text)
|
||||
endforeach()
|
||||
|
||||
add_executable(test_join_dialogs tests/test_join_dialogs.cpp src/joindialog.cpp src/inviteby.cpp src/invite_link.cpp
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/translations.qrc")
|
||||
add_dependencies(test_join_dialogs chatgui_translations)
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
#include "message_text.h"
|
||||
#include "messagedelegate.h"
|
||||
#include "debug_ui.h"
|
||||
#include <QCache>
|
||||
#include <QFontDatabase>
|
||||
#include <QFontMetricsF>
|
||||
#include <QRegularExpression>
|
||||
#include <KSyntaxHighlighting/AbstractHighlighter>
|
||||
#include <KSyntaxHighlighting/Definition>
|
||||
#include <KSyntaxHighlighting/Format>
|
||||
#include <KSyntaxHighlighting/Repository>
|
||||
#include <KSyntaxHighlighting/State>
|
||||
#include <KSyntaxHighlighting/Theme>
|
||||
|
||||
bool MessageText::isCode(int position) const {
|
||||
for (const auto& block : code) {
|
||||
if (position < block.start) break;
|
||||
if (position <= block.start + block.length) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
namespace {
|
||||
struct Marker {
|
||||
int position, length, kind; // Позиция в строке после escapes; kind: 0=**, 1=*, 2=~~.
|
||||
bool open, close; // Открытие/закрытие допустимо только около непустого текста.
|
||||
int nextClose = -1; // Следующий закрывающий маркер того же вида; поиск за O(n).
|
||||
};
|
||||
|
||||
// Разбираем строку отдельно: парные маркеры не пересекают newline и не вкладываются друг в друга.
|
||||
void appendInline(MessageText& result, const QString& source) {
|
||||
QString line;
|
||||
QVector<Marker> markers;
|
||||
const auto links = messageLinks(source);
|
||||
int linkIndex = 0;
|
||||
for (int i = 0; i < source.size();) {
|
||||
while (linkIndex < links.size() && links[linkIndex].start + links[linkIndex].length <= i) ++linkIndex;
|
||||
const bool inLink = linkIndex < links.size() && links[linkIndex].start <= i;
|
||||
const QChar ch = source[i];
|
||||
if (!inLink && ch == '\\' && i + 1 < source.size() && QStringLiteral("*~`\\").contains(source[i + 1])) {
|
||||
line += source[i + 1]; i += 2; result.changed = true; continue;
|
||||
}
|
||||
int count = 1;
|
||||
if (ch == '*' || ch == '~') {
|
||||
while (i + count < source.size() && source[i + count] == ch) ++count;
|
||||
if ((ch == '*' && count <= 2) || (ch == '~' && count == 2)) {
|
||||
const bool open = !inLink && i + count < source.size() && !source[i + count].isSpace()
|
||||
&& (i == 0 || !source[i - 1].isLetterOrNumber());
|
||||
const bool close = i > 0 && !source[i - 1].isSpace()
|
||||
&& (!inLink || i + count == links[linkIndex].start + links[linkIndex].length)
|
||||
&& (i + count == source.size() || !source[i + count].isLetterOrNumber());
|
||||
markers.append({int(line.size()), count, ch == '~' ? 2 : count == 2 ? 0 : 1, open, close});
|
||||
}
|
||||
}
|
||||
line += source.mid(i, count); i += count;
|
||||
}
|
||||
int next[3] = {-1, -1, -1};
|
||||
for (int i = int(markers.size()) - 1; i >= 0; --i) {
|
||||
auto& marker = markers[i]; marker.nextClose = next[marker.kind];
|
||||
if (marker.close) next[marker.kind] = i;
|
||||
}
|
||||
int copied = 0;
|
||||
for (int i = 0; i < markers.size(); ++i) {
|
||||
const auto& begin = markers[i];
|
||||
if (!begin.open || begin.nextClose < 0) continue;
|
||||
const auto& end = markers[begin.nextClose];
|
||||
result.text += line.mid(copied, begin.position - copied);
|
||||
QTextLayout::FormatRange span;
|
||||
span.start = result.text.size(); span.length = end.position - begin.position - begin.length;
|
||||
if (begin.kind == 0) span.format.setFontWeight(QFont::Bold);
|
||||
else if (begin.kind == 1) span.format.setFontItalic(true);
|
||||
else span.format.setFontStrikeOut(true);
|
||||
result.text += line.mid(begin.position + begin.length, span.length);
|
||||
result.styles.append(span); result.changed = true;
|
||||
copied = end.position + end.length; i = begin.nextClose;
|
||||
}
|
||||
result.text += line.mid(copied);
|
||||
}
|
||||
|
||||
// Собираем оба набора цветов за один разбор; размеры букв определяет только моноширинный шрифт блока.
|
||||
class CodeHighlighter : public KSyntaxHighlighting::AbstractHighlighter {
|
||||
public:
|
||||
MessageText& message;
|
||||
KSyntaxHighlighting::Theme light, dark;
|
||||
int offset = 0;
|
||||
CodeHighlighter(MessageText& text, KSyntaxHighlighting::Repository& repository)
|
||||
: message(text), light(repository.defaultTheme(KSyntaxHighlighting::Repository::LightTheme)),
|
||||
dark(repository.defaultTheme(KSyntaxHighlighting::Repository::DarkTheme)) { setTheme(light); }
|
||||
|
||||
void highlight(const MessageCodeBlock& block, const KSyntaxHighlighting::Definition& definition) {
|
||||
setDefinition(definition);
|
||||
KSyntaxHighlighting::State state;
|
||||
const int end = block.start + block.length;
|
||||
for (offset = block.start; offset < end;) {
|
||||
int newline = message.text.indexOf('\n', offset);
|
||||
if (newline < 0 || newline > end) newline = end;
|
||||
state = highlightLine(QStringView(message.text).mid(offset, newline - offset), state);
|
||||
offset = newline + 1;
|
||||
}
|
||||
}
|
||||
protected:
|
||||
void applyFormat(int start, int length, const KSyntaxHighlighting::Format& format) override {
|
||||
if (length <= 0) return;
|
||||
for (bool useDark : {false, true}) {
|
||||
const QColor color = format.textColor(useDark ? dark : light);
|
||||
if (!color.isValid()) continue;
|
||||
QTextLayout::FormatRange span; span.start = offset + start; span.length = length;
|
||||
span.format.setForeground(color);
|
||||
(useDark ? message.darkSyntax : message.lightSyntax).append(span);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
MessageText parseMessageText(const QString& source) {
|
||||
MessageText result;
|
||||
const auto lines = source.split('\n');
|
||||
static const QRegularExpression fence(QStringLiteral("^```([A-Za-z0-9_+#.-]{0,32})$"));
|
||||
QVector<int> closing(lines.size(), -1);
|
||||
int next = -1;
|
||||
for (int i = int(lines.size()) - 1; i >= 0; --i) {
|
||||
closing[i] = next;
|
||||
if (lines[i] == "```") next = i;
|
||||
}
|
||||
bool hasLine = false;
|
||||
for (int i = 0; i < lines.size(); ++i) {
|
||||
if (hasLine) result.text += '\n';
|
||||
hasLine = true;
|
||||
const auto opening = fence.match(lines[i]);
|
||||
if (opening.hasMatch() && closing[i] >= 0) {
|
||||
MessageCodeBlock block{int(result.text.size()), 0, opening.captured(1).toLower()};
|
||||
const int last = closing[i];
|
||||
for (int row = i + 1; row < last; ++row) {
|
||||
if (row > i + 1) result.text += '\n';
|
||||
result.text += lines[row];
|
||||
}
|
||||
block.length = result.text.size() - block.start;
|
||||
result.code.append(block); result.changed = true; i = last;
|
||||
} else appendInline(result, lines[i]);
|
||||
}
|
||||
// Ссылки ищем в готовом тексте, а код целиком исключаем из распознавания адресов.
|
||||
for (const auto& link : messageLinks(result.text)) {
|
||||
if (!result.isCode(link.start)) result.links.append(link);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
QSharedPointer<const MessageText> formattedMessageText(const QModelIndex& index) {
|
||||
const QString type = index.data(MsgContentTypeRole).toString();
|
||||
if ((!type.isEmpty() && type != "text/plain") || !index.data(MsgInviteLinkRole).toString().isEmpty()) return {};
|
||||
const QString source = index.data(MsgTextRole).toString();
|
||||
if (!source.contains('*') && !source.contains("~~") && !source.contains('`') && !source.contains('\\')) return {};
|
||||
// Ограничение учитывает текст ключа, видимый текст и диапазоны; shared pointer жив до конца paint/hit-test.
|
||||
static QCache<QString, QSharedPointer<const MessageText>> cache(8 * 1024 * 1024);
|
||||
if (const auto* cached = cache.object(source)) return *cached;
|
||||
auto message = QSharedPointer<MessageText>::create(parseMessageText(source));
|
||||
if (!message->code.isEmpty()) {
|
||||
static KSyntaxHighlighting::Repository repository;
|
||||
CodeHighlighter highlighter(*message, repository);
|
||||
static const QHash<QString, QString> aliases = {{"cpp", "C++"}, {"c++", "C++"}, {"c", "C"},
|
||||
{"py", "Python"}, {"js", "JavaScript"}, {"ts", "TypeScript"}, {"sh", "Bash"}, {"shell", "Bash"},
|
||||
{"cs", "C#"}, {"csharp", "C#"}, {"kt", "Kotlin"}, {"rs", "Rust"}, {"yml", "YAML"}};
|
||||
for (const auto& block : message->code) {
|
||||
if (block.language.isEmpty() || block.language == "text" || block.language == "plain") continue;
|
||||
auto definition = repository.definitionForName(aliases.value(block.language, block.language));
|
||||
if (!definition.isValid()) {
|
||||
for (const auto& candidate : repository.definitions()) {
|
||||
if (candidate.name().compare(block.language, Qt::CaseInsensitive) == 0) { definition = candidate; break; }
|
||||
}
|
||||
}
|
||||
if (definition.isValid()) highlighter.highlight(block, definition);
|
||||
else GUI_DEBUG("message code: unknown language=%s; using plain code", qPrintable(block.language));
|
||||
}
|
||||
}
|
||||
QSharedPointer<const MessageText> prepared = message->changed ? message : QSharedPointer<const MessageText>();
|
||||
const qint64 cost = 256 + 2LL * (source.size() + message->text.size())
|
||||
+ 256LL * (message->styles.size() + message->lightSyntax.size() + message->darkSyntax.size())
|
||||
+ 128LL * (message->code.size() + message->links.size());
|
||||
cache.insert(source, new QSharedPointer<const MessageText>(prepared), int(qMin<qint64>(cost, INT_MAX)));
|
||||
GUI_DEBUG("message text prepared: source=%d visible=%d styles=%d code=%d tokens=%d changed=%d cache_cost=%lld",
|
||||
int(source.size()), int(message->text.size()), int(message->styles.size()), int(message->code.size()),
|
||||
int(message->lightSyntax.size()), int(message->changed), (long long)cost);
|
||||
return prepared;
|
||||
}
|
||||
|
||||
qreal layoutFormattedMessage(QTextLayout& layout, const MessageText& message, int width, bool dark) {
|
||||
QString display = message.text; display.replace('\n', QChar(0x2028)); layout.setText(display);
|
||||
QFont mono = QFontDatabase::systemFont(QFontDatabase::FixedFont);
|
||||
if (layout.font().pointSizeF() > 0) mono.setPointSizeF(layout.font().pointSizeF());
|
||||
else mono.setPixelSize(layout.font().pixelSize());
|
||||
QVector<QTextLayout::FormatRange> formats = message.styles;
|
||||
for (const auto& block : message.code) {
|
||||
QTextLayout::FormatRange span; span.start = block.start; span.length = block.length;
|
||||
span.format.setFont(mono); formats.append(span);
|
||||
}
|
||||
formats += dark ? message.darkSyntax : message.lightSyntax;
|
||||
for (const auto& link : message.links) {
|
||||
QTextLayout::FormatRange span; span.start = link.start; span.length = link.length;
|
||||
span.format.setForeground(QColor(0x33, 0x90, 0xEC)); span.format.setFontUnderline(true); formats.append(span);
|
||||
}
|
||||
layout.setFormats(formats);
|
||||
QTextOption option; option.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere);
|
||||
option.setTabStopDistance(4 * QFontMetricsF(mono).horizontalAdvance(' ')); layout.setTextOption(option);
|
||||
layout.beginLayout();
|
||||
qreal height = 0;
|
||||
while (true) {
|
||||
auto line = layout.createLine();
|
||||
if (!line.isValid()) break;
|
||||
line.setLineWidth(qMax(1, width)); line.setPosition(QPointF(0, height)); height += line.height();
|
||||
}
|
||||
layout.endLayout();
|
||||
return qMax(height, QFontMetricsF(layout.font()).height());
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* Разметка текста сообщений: **жирный**, *курсив*, ~~зачёркнутый~~ и отдельные строки ```[язык]/```.
|
||||
* Исходник модели не меняется. GUI-поток получает кешированный видимый текст, ссылки и цвета синтаксиса.
|
||||
* Один QTextLayout используется для размеров, рисования и выделения; clipboard берёт MessageText::text. */
|
||||
#pragma once
|
||||
|
||||
#include "message_links.h"
|
||||
#include <QModelIndex>
|
||||
#include <QSharedPointer>
|
||||
#include <QTextLayout>
|
||||
|
||||
struct MessageCodeBlock {
|
||||
int start; // Начало кода в видимом тексте, UTF-16.
|
||||
int length; // Длина без строк-ограждений; ноль означает пустой блок.
|
||||
QString language; // Необязательное имя языка; неизвестное имя оставляет код без раскраски.
|
||||
};
|
||||
|
||||
struct MessageText {
|
||||
QString text; // Видимый текст без распознанных маркеров; настоящие переводы строк сохранены.
|
||||
QVector<QTextLayout::FormatRange> styles; // Жирный, курсив и зачёркивание; диапазоны UTF-16.
|
||||
QVector<MessageCodeBlock> code; // Непересекающиеся блоки в порядке текста.
|
||||
QVector<MessageLink> links; // Адреса вне кода, в координатах видимого текста.
|
||||
QVector<QTextLayout::FormatRange> lightSyntax, darkSyntax; // Только цвета токенов, без изменения метрик шрифта.
|
||||
bool changed = false; // Хотя бы один маркер/escape обработан; иначе применяется прежняя отрисовка.
|
||||
|
||||
// Принадлежность позиции UTF-16 к коду, включая пустые строки/блоки. Для исключения ссылок и emoji.
|
||||
bool isCode(int position) const;
|
||||
};
|
||||
|
||||
// Чистый разбор без загрузки библиотеки синтаксиса. Незакрытые маркеры остаются буквально; вложенности нет.
|
||||
MessageText parseMessageText(const QString& source);
|
||||
|
||||
// GUI-поток: кеш разметки/цветов с ограничением памяти. nullptr для обычного текста, медиа, опросов и compact invite.
|
||||
QSharedPointer<const MessageText> formattedMessageText(const QModelIndex& index);
|
||||
|
||||
// Настроить layout с уже заданным базовым font; width > 0, dark выбирает цвета. Возвращает высоту строк.
|
||||
qreal layoutFormattedMessage(QTextLayout& layout, const MessageText& message, int width, bool dark);
|
||||
Reference in new issue
Block a user