diff --git a/tools/chatgui-android/app/build.gradle.kts b/tools/chatgui-android/app/build.gradle.kts index b0790686..e2f7a495 100644 --- a/tools/chatgui-android/app/build.gradle.kts +++ b/tools/chatgui-android/app/build.gradle.kts @@ -3,6 +3,14 @@ plugins { id("org.jetbrains.kotlin.android") } +// Фон берём из desktop-чатгуи; drawable-nodpi сохраняет исходный размер плитки. +val prepareChatBackground by tasks.registering(Copy::class) { + from("../../chatgui/resources/bg.png") + into(layout.buildDirectory.dir("generated/chatBackground/res/drawable-nodpi")) + rename { "chat_background.png" } +} +tasks.named("preBuild") { dependsOn(prepareChatBackground) } + android { namespace = "com.utun.chat" compileSdk = 36 @@ -40,6 +48,7 @@ android { // Общие TGS-анимации с desktop-чатгуи, без копирования ресурсов. sourceSets["main"].assets.srcDir("../../chatgui/resources/animations") + sourceSets["main"].res.srcDir(layout.buildDirectory.dir("generated/chatBackground/res")) composeOptions { kotlinCompilerExtensionVersion = "1.5.5" diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt index da63f3fa..45eb8a40 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt @@ -3,9 +3,21 @@ package com.utun.chat.ui.components import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.ImageShader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.res.imageResource import androidx.compose.ui.unit.dp +import com.utun.chat.R import com.utun.chat.data.ConversationTarget +import com.utun.chat.data.LogManager import com.utun.chat.data.Message import com.utun.chat.viewmodel.ChatViewModel @@ -16,7 +28,20 @@ fun ConversationMessages(target: ConversationTarget, messages: List, st onPlayVoice: (String, Int) -> Unit, onSeekVoice: (String, Int, Int) -> Unit, onOpenFile: (Message) -> Unit, onPlayVideo: (Message) -> Unit, onDownloadFile: (Message) -> Unit = {}, onOpenImage: (Message) -> Unit = {}) { - LazyColumn(modifier = modifier, state = state, reverseLayout = true) { + val background = ImageBitmap.imageResource(R.drawable.chat_background) + LaunchedEffect(background) { + LogManager.addLog("DEBUG", "UI", "Chat background ready: desktop bg.png tile=${background.width}x${background.height}") + } + // Плитка закреплена за областью списка; плотность экрана сохраняет размер узора в dp. + val backgroundModifier = Modifier.drawWithCache { + val brush = ShaderBrush(ImageShader(background, TileMode.Repeated, TileMode.Repeated)) + onDrawBehind { + scale(density, density, pivot = Offset.Zero) { + drawRect(brush, size = Size(size.width / density, size.height / density)) + } + } + } + LazyColumn(modifier = modifier.then(backgroundModifier), state = state, reverseLayout = true) { item { Spacer(Modifier.height(8.dp)) } items(messages.reversed(), key = { "${target.nativeId}:${it.isOutgoing}:${it.id}" }) { message -> MessageBubble(message, playbackState, onPlayVoice, onSeekVoice, onDownloadFile, onOpenFile, onOpenImage, onPlayVideo) diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index afef7e09..849aea92 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -213,6 +213,12 @@ endif() enable_testing() +add_executable(test_emoji_playback tests/test_emoji_playback.cpp src/animtimer.cpp src/lottieicon.cpp + src/emojipanel.cpp src/emojitabbar.cpp src/emoji.cpp src/chatview.cpp) +target_link_libraries(test_emoji_playback PRIVATE ${QT_LIBS} ${QT_TEST_LIB} rlottie::rlottie ZLIB::ZLIB utun) +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_config_updater tests/test_config_updater.cpp transport/config_updater.cpp diff --git a/tools/chatgui/src/accountdelegate.cpp b/tools/chatgui/src/accountdelegate.cpp index 2b2c3841..05f5df31 100644 --- a/tools/chatgui/src/accountdelegate.cpp +++ b/tools/chatgui/src/accountdelegate.cpp @@ -14,7 +14,7 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio if (option.state & QStyle::State_Selected) painter->fillRect(option.rect, option.palette.highlight()); else if (option.state & QStyle::State_MouseOver) - painter->fillRect(option.rect, option.palette.light()); + painter->fillRect(option.rect, option.palette.alternateBase().color().lighter(115)); else painter->fillRect(option.rect, option.palette.alternateBase()); diff --git a/tools/chatgui/src/accountlist.cpp b/tools/chatgui/src/accountlist.cpp index ff7ac382..76e2b805 100644 --- a/tools/chatgui/src/accountlist.cpp +++ b/tools/chatgui/src/accountlist.cpp @@ -84,10 +84,15 @@ AccountList::AccountList(DbManager* db, QWidget *parent) m_listView->setItemDelegate(new AccountDelegate(m_listView)); m_listView->setEditTriggers(QAbstractItemView::NoEditTriggers); m_listView->setSelectionMode(QAbstractItemView::SingleSelection); + m_listView->setMouseTracking(true); m_listView->setIconSize(QSize(24, 24)); m_listView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); m_listView->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); m_listView->setContextMenuPolicy(Qt::CustomContextMenu); + DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "AccountList: mouse_tracking=%d background=%s hover=%s selected=%s", + m_listView->hasMouseTracking(), qPrintable(m_listView->palette().alternateBase().color().name()), + qPrintable(m_listView->palette().alternateBase().color().lighter(115).name()), + qPrintable(m_listView->palette().highlight().color().name())); m_detailPanel = new QWidget(this); auto* detailLayout = new QVBoxLayout(m_detailPanel); detailLayout->setContentsMargins(0, 0, 0, 0); diff --git a/tools/chatgui/src/animtimer.cpp b/tools/chatgui/src/animtimer.cpp index a4ef9a30..df008b7d 100644 --- a/tools/chatgui/src/animtimer.cpp +++ b/tools/chatgui/src/animtimer.cpp @@ -5,7 +5,6 @@ AnimTimer::AnimTimer() { m_timer.setInterval(33); connect(&m_timer, &QTimer::timeout, this, &AnimTimer::onTimer); - m_timer.start(); } AnimTimer *AnimTimer::instance() { @@ -15,6 +14,7 @@ AnimTimer *AnimTimer::instance() { void AnimTimer::shutdown() { instance()->m_timer.stop(); + instance()->m_playback.invalidate(); instance()->m_icons.clear(); DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "AnimTimer: shutdown"); } @@ -31,22 +31,29 @@ LottieIcon *AnimTimer::icon(const QString &key) const { return m_icons.value(key, nullptr); } -void AnimTimer::activate() { - if (m_activeRef++ == 0) { - for (auto *icon : m_icons) - icon->advanceFrame(); - emit ticked(); +void AnimTimer::play() { + if (m_icons.isEmpty()) { + GUI_WARN("AnimTimer: playback requested without registered icons"); + return; } -} - -void AnimTimer::deactivate() { - if (m_activeRef > 0) m_activeRef--; + GUI_DEBUG("AnimTimer: hover trigger icons=%d running=%d playback_ms=%d", (int)m_icons.size(), m_timer.isActive(), PlaybackMs); + m_playback.start(); + m_timer.start(); } void AnimTimer::onTimer() { - if (m_activeRef <= 0) return; - for (auto *icon : m_icons) - icon->advanceFrame(); + const qint64 elapsed = m_playback.elapsed(); + const bool finishing = elapsed >= PlaybackMs; + bool finished = true; + for (auto *icon : m_icons) { + if (!finishing || icon->currentFrame() != 0) icon->advanceFrame(); + if (icon->currentFrame() != 0) finished = false; + } + if (finishing && finished) { + m_timer.stop(); + m_playback.invalidate(); + GUI_DEBUG("AnimTimer: playback finished elapsed_ms=%lld icons=%d all_frames=0", (long long)elapsed, (int)m_icons.size()); + } emit ticked(); } diff --git a/tools/chatgui/src/animtimer.h b/tools/chatgui/src/animtimer.h index 913a44ac..f0eea177 100644 --- a/tools/chatgui/src/animtimer.h +++ b/tools/chatgui/src/animtimer.h @@ -3,6 +3,7 @@ #include #include #include +#include class LottieIcon; @@ -16,8 +17,9 @@ public: LottieIcon *icon(const QString &key) const; void logRegisteredIcons() const; - void activate(); - void deactivate(); + // Наведение запускает минимум две секунды воспроизведения, затем каждый значок завершает свой цикл. + static constexpr int PlaybackMs = 2000; + void play(); static void shutdown(); signals: @@ -30,5 +32,5 @@ private: AnimTimer(); QTimer m_timer; QHash m_icons; - int m_activeRef = 0; + QElapsedTimer m_playback; }; diff --git a/tools/chatgui/src/chatview.cpp b/tools/chatgui/src/chatview.cpp index 043a4eb3..2fd411a1 100644 --- a/tools/chatgui/src/chatview.cpp +++ b/tools/chatgui/src/chatview.cpp @@ -1,5 +1,7 @@ #include "chatview.h" #include "messagedelegate.h" +#include "emoji.h" +#include "debug_ui.h" #include #include #include @@ -8,6 +10,20 @@ #include #include +// Текст и реакции сообщения используют общий набор анимированных значков. +static QSet animatedEmojiPaths(const QModelIndex &index) { + QSet paths; + const QString text = index.data(MsgTextRole).toString(); + for (auto it = g_animatedEmojiMap.constBegin(); it != g_animatedEmojiMap.constEnd(); ++it) { + if (text.contains(it.key())) paths.insert(it.value()); + } + for (const auto &reaction : index.data(MsgReactionsRole).toList()) { + const QString path = g_animatedEmojiMap.value(reaction.toMap().value("emoji").toString()); + if (!path.isEmpty()) paths.insert(path); + } + return paths; +} + ChatView::ChatView(QWidget *parent) : QListView(parent) { @@ -65,6 +81,24 @@ void ChatView::paintEvent(QPaintEvent *event) { p.drawTiledPixmap(viewport()->rect(), m_background); } QListView::paintEvent(event); + QSet visible; + if (model()) { + const QRect area = viewport()->rect(); + for (int row = 0; row < model()->rowCount(); ++row) { + const QModelIndex index = model()->index(row, 0); + if (!visualRect(index).intersects(area)) continue; + const QString message = QStringLiteral("%1/%2/%3/") + .arg(index.data(MsgChannelIdRole).toString()).arg(index.data(MsgIdRole).toLongLong()) + .arg(index.data(MsgIsOutgoingRole).toInt()); + for (const auto &path : animatedEmojiPaths(index)) visible.insert(message + path); + } + } + const int appeared = (visible - m_visibleAnimations).size(); + m_visibleAnimations = visible; + if (appeared > 0) { + GUI_DEBUG("ChatView: visible animation trigger appeared=%d visible=%d", appeared, (int)visible.size()); + emit animationTriggered(); + } } bool ChatView::isRowSelected(const QModelIndex &idx) const { @@ -95,6 +129,13 @@ void ChatView::mousePressEvent(QMouseEvent *event) { QListView::mousePressEvent(event); } +bool ChatView::viewportEvent(QEvent *event) { + if (event->type() == QEvent::Leave && m_lastHovered.isValid()) { + m_lastHovered = QPersistentModelIndex(); + } + return QListView::viewportEvent(event); +} + void ChatView::mouseMoveEvent(QMouseEvent *event) { if (!m_selecting && m_pressIndex.isValid()) { int dx = event->pos().x() - m_pressPos.x(); @@ -115,7 +156,11 @@ void ChatView::mouseMoveEvent(QMouseEvent *event) { QModelIndex idx = indexAt(viewport()->mapFrom(this, event->pos())); if (idx != QModelIndex(m_lastHovered)) { m_lastHovered = idx; - emit hoveredIndexChanged(idx); + const auto paths = animatedEmojiPaths(idx); + if (!paths.isEmpty()) { + GUI_DEBUG("ChatView: hover animation trigger row=%d icons=%d", idx.row(), (int)paths.size()); + emit animationTriggered(); + } } } if (!m_selecting) { diff --git a/tools/chatgui/src/chatview.h b/tools/chatgui/src/chatview.h index 694c3e74..f96d9cec 100644 --- a/tools/chatgui/src/chatview.h +++ b/tools/chatgui/src/chatview.h @@ -4,6 +4,7 @@ #include #include #include +#include struct ChatTextSelection { QPersistentModelIndex index; @@ -31,9 +32,10 @@ public: void setTextSelChars(int start, int end) { m_textSel.startChar = start; m_textSel.endChar = end; } signals: - void hoveredIndexChanged(const QModelIndex &idx); + void animationTriggered(); protected: + bool viewportEvent(QEvent *event) override; void paintEvent(QPaintEvent *event) override; void resizeEvent(QResizeEvent *event) override; void scrollContentsBy(int dx, int dy) override; @@ -51,6 +53,7 @@ private: QPersistentModelIndex m_selEnd; ChatTextSelection m_textSel; QPersistentModelIndex m_lastHovered; + QSet m_visibleAnimations; QPersistentModelIndex m_pressIndex; QPoint m_pressPos; bool m_selecting = false; diff --git a/tools/chatgui/src/emojipanel.cpp b/tools/chatgui/src/emojipanel.cpp index 01eeed96..e98da333 100644 --- a/tools/chatgui/src/emojipanel.cpp +++ b/tools/chatgui/src/emojipanel.cpp @@ -3,6 +3,7 @@ #include "emojitabbar.h" #include "animtimer.h" #include "lottieicon.h" +#include "debug_ui.h" #include #include #include @@ -38,6 +39,12 @@ public: connect(&m_timer, &QTimer::timeout, this, [this]() { if (m_icon && m_icon->isValid()) { m_frame = (m_frame + 1) % m_icon->frameCount(); + if (m_playback.elapsed() >= AnimTimer::PlaybackMs && m_frame == 0) { + GUI_DEBUG("EmojiButton: playback finished path=%s elapsed_ms=%lld frame=0", + qPrintable(m_data.animPath), (long long)m_playback.elapsed()); + m_timer.stop(); + m_playback.invalidate(); + } update(); } }); @@ -53,19 +60,16 @@ protected: #endif QPushButton::enterEvent(e); if (m_icon && m_icon->isValid()) { - m_hovered = true; - m_frame = 0; + GUI_DEBUG("EmojiButton: hover trigger path=%s running=%d frame=%d playback_ms=%d", + qPrintable(m_data.animPath), m_timer.isActive(), m_frame, AnimTimer::PlaybackMs); + if (!m_timer.isActive()) m_frame = 0; + m_playback.start(); m_timer.start(); + update(); } } - void leaveEvent(QEvent *e) override { - QPushButton::leaveEvent(e); - m_hovered = false; - m_timer.stop(); - update(); - } void paintEvent(QPaintEvent *e) override { - if (m_hovered && m_icon && m_icon->isValid()) { + if (m_timer.isActive() && m_icon && m_icon->isValid()) { QPainter p(this); p.setRenderHint(QPainter::SmoothPixmapTransform); int sz = qMin(width(), height()) - 4; @@ -82,7 +86,7 @@ private: LottieIcon *m_icon = nullptr; QTimer m_timer; int m_frame = 0; - bool m_hovered = false; + QElapsedTimer m_playback; }; } // namespace diff --git a/tools/chatgui/src/messagelist.cpp b/tools/chatgui/src/messagelist.cpp index 874dad88..6f57d993 100644 --- a/tools/chatgui/src/messagelist.cpp +++ b/tools/chatgui/src/messagelist.cpp @@ -597,26 +597,7 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent m_view->viewport()->update(); }); - connect(m_view, &ChatView::hoveredIndexChanged, this, [this](const QModelIndex &idx) { - bool hasAnim = false; - if (idx.isValid()) { - QString text = idx.data(MsgTextRole).toString(); - for (auto it = g_animatedEmojiMap.begin(); it != g_animatedEmojiMap.end(); ++it) { - if (text.contains(it.key())) { hasAnim = true; break; } - } - if (!hasAnim) { - QVariantList reactions = idx.data(MsgReactionsRole).toList(); - for (const QVariant &rv : reactions) { - if (g_animatedEmojiMap.contains(rv.toMap()["emoji"].toString())) - { hasAnim = true; break; } - } - } - } - bool wasActive = m_animHoverActive; - m_animHoverActive = hasAnim; - if (!wasActive && m_animHoverActive) AnimTimer::instance()->activate(); - else if (wasActive && !m_animHoverActive) AnimTimer::instance()->deactivate(); - }); + connect(m_view, &ChatView::animationTriggered, AnimTimer::instance(), &AnimTimer::play); m_readDebounce = new QTimer(this); m_readDebounce->setSingleShot(true); diff --git a/tools/chatgui/src/messagelist.h b/tools/chatgui/src/messagelist.h index 7d089826..500d40a2 100644 --- a/tools/chatgui/src/messagelist.h +++ b/tools/chatgui/src/messagelist.h @@ -52,7 +52,6 @@ private: EmojiPanel *m_emojiPanel; DbManager *m_db; QString m_currentChannelId; - bool m_animHoverActive = false; QHash m_avatarCache; qint64 m_lastReadMsgId = 0; QTimer* m_readDebounce; diff --git a/tools/chatgui/tests/test_emoji_playback.cpp b/tools/chatgui/tests/test_emoji_playback.cpp new file mode 100644 index 00000000..8188ef4e --- /dev/null +++ b/tools/chatgui/tests/test_emoji_playback.cpp @@ -0,0 +1,201 @@ +#include "../src/animtimer.h" +#include "../src/chatview.h" +#include "../src/emoji.h" +#include "../src/messagedelegate.h" +#include "../src/emojipanel.h" +#include "../src/lottieicon.h" +#include "../../../lib/debug_config.h" +#include +#include +#include +#include +#include +#include +#include + +static QStringList playbackLog; + +static void captureLog(int, const char *, const char *message) { + playbackLog.append(QString::fromUtf8(message)); +} + +// Два коротких цикла разной длины выявляют остановку посреди кадра и ожидание общего кратного. +static QByteArray animationData(int frames) { + QByteArray json = QString(R"({"v":"5.7.4","fr":30,"ip":0,"op":%1,"w":32,"h":32,"layers":[]})").arg(frames).toUtf8(); + QByteArray gzip(1024, '\0'); + z_stream stream = {}; + if (deflateInit2(&stream, Z_DEFAULT_COMPRESSION, Z_DEFLATED, 16 + MAX_WBITS, 8, Z_DEFAULT_STRATEGY) != Z_OK) return {}; + stream.next_in = reinterpret_cast(json.data()); + stream.avail_in = json.size(); + stream.next_out = reinterpret_cast(gzip.data()); + stream.avail_out = gzip.size(); + int result = deflate(&stream, Z_FINISH); + gzip.resize(stream.total_out); + deflateEnd(&stream); + return result == Z_STREAM_END ? gzip : QByteArray(); +} + +class EmojiPlaybackTest : public QObject { + Q_OBJECT + std::unique_ptr shortIcon, longIcon; + +private slots: + void init() { + debug_config_init(); + debug_set_category_level(DEBUG_CATEGORY_GENERAL, DEBUG_LEVEL_DEBUG); + debug_set_log_hook(captureLog); + playbackLog.clear(); + shortIcon = std::make_unique(animationData(7), "short"); + longIcon = std::make_unique(animationData(11), "long"); + QVERIFY(shortIcon->isValid()); + QVERIFY(longIcon->isValid()); + AnimTimer::instance()->registerIcon("short", shortIcon.get()); + AnimTimer::instance()->registerIcon("long", longIcon.get()); + } + + void cleanup() { + AnimTimer::shutdown(); + debug_set_log_hook(nullptr); + shortIcon.reset(); + longIcon.reset(); + } + + void messagesFinishEachCycle() { + auto *timer = AnimTimer::instance(); + QSignalSpy ticks(timer, &AnimTimer::ticked); + timer->play(); + QTest::qWait(1850); + QVERIFY(playbackLog.filter("AnimTimer: playback finished").isEmpty()); + int count = ticks.count(); + QTest::qWait(100); + QVERIFY(ticks.count() > count); + QTRY_COMPARE_WITH_TIMEOUT(playbackLog.filter("AnimTimer: playback finished").size(), 1, 1000); + QCOMPARE(shortIcon->currentFrame(), 0); + QCOMPARE(longIcon->currentFrame(), 0); + count = ticks.count(); + QTest::qWait(150); + QCOMPARE(ticks.count(), count); + } + + void anotherHoverRestartsDeadline() { + auto *timer = AnimTimer::instance(); + QSignalSpy ticks(timer, &AnimTimer::ticked); + timer->play(); + QTest::qWait(1400); + timer->play(); + QTest::qWait(1000); + QVERIFY(playbackLog.filter("AnimTimer: playback finished").isEmpty()); + int count = ticks.count(); + QTest::qWait(100); + QVERIFY(ticks.count() > count); + QTRY_COMPARE_WITH_TIMEOUT(playbackLog.filter("AnimTimer: playback finished").size(), 1, 1500); + QCOMPARE(shortIcon->currentFrame(), 0); + QCOMPARE(longIcon->currentFrame(), 0); + } + + void pickerFinishesWithOrWithoutHover() { + AnimTimer::instance()->registerIcon(":/animations/robot.tgs", shortIcon.get()); + EmojiPanel panel; + panel.resize(320, 240); + panel.findChild()->setCurrentIndex(3); + panel.show(); + QPushButton *button = nullptr; + for (auto *candidate : panel.findChildren()) { + if (candidate->toolTip() == ":robot:" && candidate->hasMouseTracking()) button = candidate; + } + QVERIFY(button); + QEvent leave(QEvent::Leave); + QApplication::sendEvent(button, &leave); +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + QEnterEvent enter(QPointF(1, 1), QPointF(1, 1), QPointF(1, 1)); +#else + QEvent enter(QEvent::Enter); +#endif + QApplication::sendEvent(button, &enter); + QTest::qWait(1850); + QVERIFY(playbackLog.filter("EmojiButton: playback finished").isEmpty()); + QTRY_COMPARE_WITH_TIMEOUT(playbackLog.filter("EmojiButton: playback finished").size(), 1, 1000); + QTest::qWait(150); + QCOMPARE(playbackLog.filter("EmojiButton: hover trigger").size(), 1); + + QApplication::sendEvent(button, &leave); + QApplication::sendEvent(button, &enter); + QTest::qWait(100); + QApplication::sendEvent(button, &leave); + QTest::qWait(1750); + QCOMPARE(playbackLog.filter("EmojiButton: playback finished").size(), 1); + QTRY_COMPARE_WITH_TIMEOUT(playbackLog.filter("EmojiButton: playback finished").size(), 2, 1000); + } + + void visibleMessagesTriggerOnce() { + builtinEmojiSet(); + QStandardItemModel model; + for (int row = 0; row < 6; ++row) { + auto *item = new QStandardItem("message"); + item->setSizeHint(QSize(200, 60)); + item->setData(row + 1, MsgIdRole); + item->setData("test-channel", MsgChannelIdRole); + item->setData(row == 0 || row == 5 ? QString::fromUtf8("🤖") : QString("text"), MsgTextRole); + model.appendRow(item); + } + ChatView view; + view.setModel(&model); + view.resize(240, 140); + QSignalSpy triggers(&view, &ChatView::animationTriggered); + connect(&view, &ChatView::animationTriggered, AnimTimer::instance(), &AnimTimer::play); + view.show(); + QTRY_COMPARE(triggers.count(), 1); + view.viewport()->update(); + QTest::qWait(100); + QCOMPARE(triggers.count(), 1); + + view.scrollToBottom(); + QTRY_COMPARE(triggers.count(), 2); + view.scrollToTop(); + QTRY_COMPARE(triggers.count(), 3); + view.scrollToBottom(); + QTRY_COMPARE(triggers.count(), 4); + + auto *message = new QStandardItem("new message"); + message->setSizeHint(QSize(200, 60)); + message->setData(7, MsgIdRole); + message->setData("test-channel", MsgChannelIdRole); + message->setData(QString::fromUtf8("🤖"), MsgTextRole); + model.appendRow(message); + view.scrollToBottom(); + QTRY_COMPARE(triggers.count(), 5); + view.viewport()->update(); + QTest::qWait(100); + QCOMPARE(triggers.count(), 5); + } + + void hoverTriggersOnEntry() { + builtinEmojiSet(); + QStandardItemModel model; + auto *message = new QStandardItem("message"); + message->setSizeHint(QSize(200, 60)); + message->setData(1, MsgIdRole); + message->setData(QString::fromUtf8("🤖"), MsgTextRole); + model.appendRow(message); + ChatView view; + view.setModel(&model); + view.resize(240, 140); + view.show(); + QTest::qWait(50); + QSignalSpy triggers(&view, &ChatView::animationTriggered); + const QPointF point = view.visualRect(model.index(0, 0)).center(); + QMouseEvent move(QEvent::MouseMove, point, point, Qt::NoButton, Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent(view.viewport(), &move); + QCOMPARE(triggers.count(), 1); + QApplication::sendEvent(view.viewport(), &move); + QCOMPARE(triggers.count(), 1); + QEvent leave(QEvent::Leave); + QApplication::sendEvent(view.viewport(), &leave); + QCOMPARE(triggers.count(), 1); + QApplication::sendEvent(view.viewport(), &move); + QCOMPARE(triggers.count(), 2); + } +}; + +QTEST_MAIN(EmojiPlaybackTest) +#include "test_emoji_playback.moc"