diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 075054bf9..0cf599238 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -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) diff --git a/tools/chatgui/src/message_text.cpp b/tools/chatgui/src/message_text.cpp new file mode 100644 index 000000000..ac4248735 --- /dev/null +++ b/tools/chatgui/src/message_text.cpp @@ -0,0 +1,213 @@ +#include "message_text.h" +#include "messagedelegate.h" +#include "debug_ui.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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 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 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> cache(8 * 1024 * 1024); + if (const auto* cached = cache.object(source)) return *cached; + auto message = QSharedPointer::create(parseMessageText(source)); + if (!message->code.isEmpty()) { + static KSyntaxHighlighting::Repository repository; + CodeHighlighter highlighter(*message, repository); + static const QHash 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 prepared = message->changed ? message : QSharedPointer(); + 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(prepared), int(qMin(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 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()); +} diff --git a/tools/chatgui/src/message_text.h b/tools/chatgui/src/message_text.h new file mode 100644 index 000000000..9281110b3 --- /dev/null +++ b/tools/chatgui/src/message_text.h @@ -0,0 +1,36 @@ +/* Разметка текста сообщений: **жирный**, *курсив*, ~~зачёркнутый~~ и отдельные строки ```[язык]/```. + * Исходник модели не меняется. GUI-поток получает кешированный видимый текст, ссылки и цвета синтаксиса. + * Один QTextLayout используется для размеров, рисования и выделения; clipboard берёт MessageText::text. */ +#pragma once + +#include "message_links.h" +#include +#include +#include + +struct MessageCodeBlock { + int start; // Начало кода в видимом тексте, UTF-16. + int length; // Длина без строк-ограждений; ноль означает пустой блок. + QString language; // Необязательное имя языка; неизвестное имя оставляет код без раскраски. +}; + +struct MessageText { + QString text; // Видимый текст без распознанных маркеров; настоящие переводы строк сохранены. + QVector styles; // Жирный, курсив и зачёркивание; диапазоны UTF-16. + QVector code; // Непересекающиеся блоки в порядке текста. + QVector links; // Адреса вне кода, в координатах видимого текста. + QVector lightSyntax, darkSyntax; // Только цвета токенов, без изменения метрик шрифта. + bool changed = false; // Хотя бы один маркер/escape обработан; иначе применяется прежняя отрисовка. + + // Принадлежность позиции UTF-16 к коду, включая пустые строки/блоки. Для исключения ссылок и emoji. + bool isCode(int position) const; +}; + +// Чистый разбор без загрузки библиотеки синтаксиса. Незакрытые маркеры остаются буквально; вложенности нет. +MessageText parseMessageText(const QString& source); + +// GUI-поток: кеш разметки/цветов с ограничением памяти. nullptr для обычного текста, медиа, опросов и compact invite. +QSharedPointer formattedMessageText(const QModelIndex& index); + +// Настроить layout с уже заданным базовым font; width > 0, dark выбирает цвета. Возвращает высоту строк. +qreal layoutFormattedMessage(QTextLayout& layout, const MessageText& message, int width, bool dark);