Files
utun2/tools/chatgui/src/messagedelegate.cpp
T

1729 lines
76 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include <QCoreApplication>
#include "messagedelegate.h"
#include "message_links.h"
#include "chatview.h"
#include "debug_ui.h"
#include "emoji.h"
#include "animtimer.h"
#include "lottieicon.h"
#include "voiceplayback.h"
#include "sound_manager.h"
#include <cmath>
#include <QPainter>
#include <QPainterPath>
#include <QApplication>
#include <QAbstractItemView>
#include <QFontMetrics>
#include <QTextLayout>
#include <QMouseEvent>
#include <QMap>
#include <QDateTime>
#include <QTimer>
#include <QFile>
#include <QFileInfo>
#include <QDesktopServices>
#include <QUrl>
#include <QDebug>
#include <QImageReader>
#include <QPersistentModelIndex>
/* Один layout для размеров, отрисовки, выделения и hit-test ссылок. */
static qreal layoutMessageText(QTextLayout& layout, int width, const QString& compactLink = {}) {
int linkStart = -1;
if (!compactLink.isEmpty() && layout.text().endsWith(compactLink)) {
linkStart = layout.text().length() - compactLink.length();
const QString shortened = QFontMetrics(layout.font()).elidedText(compactLink, Qt::ElideRight, qMax(1, width - 1));
layout.setText(layout.text().left(linkStart) + shortened);
}
QTextOption option; option.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere);
layout.setTextOption(option);
QVector<QTextLayout::FormatRange> formats;
const auto links = linkStart >= 0 ? QVector<MessageLink>{{linkStart, (int)layout.text().length() - linkStart, compactLink}}
: messageLinks(layout.text());
for (const auto& link : links) {
QTextLayout::FormatRange range;
range.start = link.start; range.length = link.length;
range.format.setForeground(QColor(0x33, 0x90, 0xEC)); range.format.setFontUnderline(true);
formats.append(range);
}
layout.setFormats(formats);
layout.beginLayout();
qreal y = 0;
while (true) {
auto line = layout.createLine();
if (!line.isValid()) break;
line.setLineWidth(qMax(1, width)); line.setPosition(QPointF(0, y)); y += line.height();
}
layout.endLayout();
return y;
}
QString MessageDelegate::linkAt(const QStyleOptionViewItem& option, const QModelIndex& index, const QPoint& point) const {
const QString text = index.data(MsgTextRole).toString();
if (messageLinks(text).isEmpty()) return {};
const Layout geometry = calcLayout(option, index);
if (geometry.isVoice || geometry.isFile || geometry.isVideo || geometry.isImage || geometry.emojiOnly) return {};
const QPoint local = point - option.rect.topLeft() - geometry.textRect.topLeft();
if (!QRect(QPoint(0, 0), geometry.textRect.size()).contains(local)) return {};
QString display = text; display.replace('\n', QChar(0x2028));
QTextLayout layout(display, option.font);
const QString compactLink = index.data(MsgInviteLinkRole).toString();
layoutMessageText(layout, geometry.textRect.width(), compactLink);
for (int i = 0; i < layout.lineCount(); ++i) {
const auto line = layout.lineAt(i);
if (local.y() < line.y() || local.y() >= line.y() + line.height()) continue;
if (local.x() < line.x() || local.x() >= line.x() + line.naturalTextWidth()) return {};
const int position = line.xToCursor(local.x(), QTextLine::CursorOnCharacter);
for (const auto& range : layout.formats()) {
if (position >= range.start && position < range.start + range.length)
return compactLink.isEmpty() ? text.mid(range.start, range.length) : compactLink;
}
}
return {};
}
MessageDelegate::MessageDelegate(QObject *parent)
: QStyledItemDelegate(parent) {
GUI_DEBUG("MessageDelegate: avatar size=%dpx", kAvatar);
}
struct VoicePlayState {
uint64_t playbackId = 0;
bool loading = false;
bool playing = false;
bool paused = false;
qint64 startMs = 0;
float durationSec = 0.0f;
float pausedPosSec = 0.0f;
float displayFrac = 0.0f;
float displayElapsed = 0.0f;
};
static QMap<QString, VoicePlayState> s_voiceStates;
static QTimer* s_voiceTimer = nullptr;
static QAbstractItemView* s_voiceView = nullptr;
static void ensureVoiceTimer() {
if (!s_voiceTimer) {
s_voiceTimer = new QTimer();
s_voiceTimer->setInterval(16);
QObject::connect(s_voiceTimer, &QTimer::timeout, []() {
bool anyActive = false;
SoundManager* sm = SoundManager::instance();
for (auto it = s_voiceStates.begin(); it != s_voiceStates.end(); ++it) {
if (it->loading) { anyActive = true; continue; }
if (!it->playing && !it->paused) {
if (it->displayFrac > 0.001f || it->displayElapsed > 0.001f) anyActive = true;
continue;
}
if (it->paused) { anyActive = true; continue; }
if (!sm->isPcmPlaying(it->playbackId)) {
it->playing = false;
it->paused = false;
anyActive = true;
} else {
anyActive = true;
}
}
if (!anyActive) s_voiceTimer->stop();
if (s_voiceView) s_voiceView->viewport()->update();
});
}
}
bool MessageDelegate::editorEvent(QEvent *event, QAbstractItemModel *model,
const QStyleOptionViewItem &option,
const QModelIndex &index) {
if (event->type() != QEvent::MouseButtonPress && event->type() != QEvent::MouseButtonRelease) return false;
Layout L = calcLayout(option, index);
const auto* mouse = static_cast<QMouseEvent*>(event);
if (mouse->button() != Qt::LeftButton) return false;
const QPoint point = mouse->pos() - option.rect.topLeft();
const bool onQuote = L.quoteRect.contains(point);
int reaction = L.reactionHeart.contains(point) ? -1 : -2;
for (const auto& chip : L.reactionChips) if (chip.rect.contains(point)) { reaction = chip.id; break; }
const bool onReaction = mouse->button() == Qt::LeftButton && reaction != -2;
const bool onAvatar=mouse->button()==Qt::LeftButton && !L.narrow && L.avatarRect.contains(mouse->pos()-option.rect.topLeft());
const bool onPoll = !onQuote && index.data(MsgContentTypeRole).toString() == "poll"
&& !index.data(MsgChannelIdRole).toString().startsWith("dm:")
&& (L.bubbleRect.contains(point) || L.emojiArea.contains(point))
&& (!L.reactionHeart.isValid() || point.y() < L.reactionHeart.top());
if (event->type()==QEvent::MouseButtonPress) {
m_quotePress = onQuote ? index : QModelIndex(); m_quotePressPosition = mouse->pos();
m_reactionPress = onReaction ? index : QModelIndex(); m_pressedReaction = reaction; m_reactionPressPosition = mouse->pos();
m_avatarPress=onAvatar ? index : QModelIndex(); m_avatarPressPosition=mouse->pos();
m_pollPress = onPoll ? index : QModelIndex(); m_pollPressPosition = mouse->pos();
return onQuote || onAvatar || onReaction || onPoll;
}
const auto* view = qobject_cast<const ChatView*>(option.widget);
const bool hadQuotePress = m_quotePress.isValid();
const bool activateQuote = onQuote && m_quotePress == index
&& (mouse->pos() - m_quotePressPosition).manhattanLength() < 5 && (!view || !view->hasSelection());
m_quotePress = QPersistentModelIndex();
const bool activatePoll = onPoll && m_pollPress == index && (mouse->pos() - m_pollPressPosition).manhattanLength() < 5
&& (!view || !view->textSelection().active);
m_pollPress = QPersistentModelIndex();
const bool hadReactionPress = m_reactionPress.isValid();
const bool activateAvatar=onAvatar && m_avatarPress==index && (mouse->pos()-m_avatarPressPosition).manhattanLength()<5;
m_avatarPress=QPersistentModelIndex();
const bool activateReaction = onReaction && m_reactionPress == index && m_pressedReaction == reaction
&& (mouse->pos() - m_reactionPressPosition).manhattanLength() < 5;
m_reactionPress = QPersistentModelIndex(); m_pressedReaction = -2;
if (activateQuote) { emit replyRequested(index.data(MsgReplyToRole).toString()); return true; }
if (hadQuotePress || onQuote) return true;
if (activateReaction) { emit reactionRequested(index, reaction); return true; }
if (hadReactionPress || onReaction) return true;
if (activateAvatar) {
emit avatarRequested(index.data(MsgChannelIdRole).toString(), index.data(MsgAuthorNodeIdRole).toULongLong());
return true;
}
if (onAvatar) return true;
if (L.reactionHeart.isValid() && point.y() >= L.reactionHeart.top()) return true;
if (onPoll) {
if (!activatePoll) return true;
const QString messageId = index.data(MsgMessageKeyRole).toString();
if (messageId.size() != 64) {
GUI_WARN("poll: cannot open message with invalid key row=%d key_bytes=%d", index.row(), int(messageId.size()));
return true;
}
GUI_INFO("poll: left click ch=%s message=%s", qPrintable(index.data(MsgChannelIdRole).toString()), qPrintable(messageId));
emit pollRequested(messageId);
return true;
}
if (L.isVideo) {
int dlState = index.data(MsgFileDownloadStateRole).toInt();
if (dlState == 2) { /* скачано — открыть плеер */
QString fp = index.data(MsgVoiceFileRole).toString();
if (!fp.isEmpty() && QFile::exists(fp) && m_videoOpenCb) {
m_videoOpenCb(fp);
return true;
}
/* файл пропал (вычистка кеша/удаление) — перекачиваем ниже */
}
if (dlState == 1) return false; /* скачивается */
return m_downloadCb && m_downloadCb(index);
}
if (L.isImage) {
int dlState = index.data(MsgFileDownloadStateRole).toInt();
if (dlState == 2) { /* скачано — открыть просмотрщик */
QString fp = index.data(MsgVoiceFileRole).toString();
if (!fp.isEmpty() && QFile::exists(fp) && m_imageOpenCb) {
m_imageOpenCb(fp);
return true;
}
/* файл пропал (вычистка кеша/удаление) — перекачиваем ниже */
}
if (dlState == 1) return false; /* скачивается */
return m_downloadCb && m_downloadCb(index);
}
if (L.isFile) {
int dlState = index.data(MsgFileDownloadStateRole).toInt();
qint64 msgId = index.data(MsgIdRole).toLongLong();
QString chId = index.data(MsgChannelIdRole).toString();
if (chId.startsWith(QStringLiteral("dm:"))) return false; /* PM сохраняется через контекстное меню. */
if (dlState == 2) { /* downloaded: open file */
QString fp = index.data(MsgVoiceFileRole).toString();
if (!fp.isEmpty() && QFile::exists(fp)) {
QDesktopServices::openUrl(QUrl::fromLocalFile(fp));
return true;
}
/* файл пропал (вычистка кеша/удаление) — перекачиваем ниже */
}
if (dlState == 1) return false; /* downloading — no action */
return m_downloadCb && m_downloadCb(index);
}
if (!L.isVoice) return false;
QMouseEvent* me = static_cast<QMouseEvent*>(event);
QPoint localPos = me->pos() - option.rect.topLeft();
bool onPlayBtn = L.voicePlayBtnRect.contains(localPos);
bool onWaveform = L.voiceWaveformRect.contains(localPos);
if (!onPlayBtn && !onWaveform) return false;
QString filePath = index.data(MsgVoiceFileRole).toString();
int dlState = index.data(MsgFileDownloadStateRole).toInt();
qint64 msgId = index.data(MsgIdRole).toLongLong();
QString chId = index.data(MsgChannelIdRole).toString();
GUI_DEBUG("editorEvent voice: playBtn=%d wave=%d file='%s' exists=%d dlState=%d msgId=%lld",
(int)onPlayBtn, (int)onWaveform, qPrintable(filePath), QFile::exists(filePath),
dlState, (long long)msgId);
/* not downloaded (or file evicted): start download (play will start on completion) */
bool fileMissing = filePath.isEmpty() || !QFile::exists(filePath);
if (fileMissing && dlState != 1) {
if (!m_downloadCb || !m_downloadCb(index)) return false;
model->setData(index, true, MsgVoiceDownloadPendingRole);
return true;
}
if (fileMissing) return false;
GUI_DEBUG("editorEvent voice click: filePath=%s exists=%d wave=%d",
qPrintable(filePath), QFile::exists(filePath), (int)onWaveform);
if (filePath.isEmpty()) return false;
ensureVoiceTimer();
VoicePlayState& st = s_voiceStates[filePath];
if (st.loading) return true;
QString durStr = index.data(MsgVoiceDurationRole).toString();
durStr.remove('s');
float dur = durStr.toFloat();
if (dur <= 0) dur = 1.0f;
if (onWaveform) {
float clickFrac = (float)(localPos.x() - L.voiceWaveformRect.x()) / L.voiceWaveformRect.width();
if (clickFrac < 0) clickFrac = 0; if (clickFrac > 1) clickFrac = 1;
float targetSec = clickFrac * dur;
qint64 now = QDateTime::currentMSecsSinceEpoch();
if (!st.playing && !st.paused) {
/* Start playback then seek */
st.loading = true;
QPersistentModelIndex persistent(index);
VoicePlayer::playOpusFile(filePath, this, [filePath, clickFrac, persistent](float actualDur, uint64_t playbackId) {
s_voiceStates[filePath].loading = false;
if (actualDur <= 0) return;
MessageDelegate::startAutoPlay(filePath, actualDur, playbackId);
float position = clickFrac * actualDur;
auto& state = s_voiceStates[filePath];
state.startMs -= (qint64)(position * 1000); state.pausedPosSec = position;
state.displayFrac = clickFrac; state.displayElapsed = position;
int rate = SoundManager::instance()->pcmSampleRate(playbackId);
if (rate > 0) SoundManager::instance()->seekPcm(playbackId, (unsigned long long)(position * rate));
if (persistent.isValid()) const_cast<QAbstractItemModel*>(persistent.model())->setData(persistent, 1, MsgVoicePlayStateRole);
});
s_voiceTimer->start();
} else {
/* Currently playing or paused — seek */
st.startMs = now - (qint64)(targetSec * 1000.0);
if (st.paused)
st.pausedPosSec = targetSec;
else if (!st.playing)
{ st.playing = true; s_voiceTimer->start(); }
st.displayFrac = (targetSec > 0 && dur > 0) ? (targetSec / dur) : 0;
st.displayElapsed = targetSec;
int sr = SoundManager::instance()->pcmSampleRate(st.playbackId);
if (sr > 0)
SoundManager::instance()->seekPcm(st.playbackId, (unsigned long long)(targetSec * sr));
}
return true;
}
/* --- Play button click (original logic) --- */
bool isOtherPlaying = false;
for (auto it = s_voiceStates.begin(); it != s_voiceStates.end(); ++it) {
if (it.key() != filePath && (it->playing || it->paused))
isOtherPlaying = true;
}
if (st.playing) {
/* Playing → Pause */
qint64 now = QDateTime::currentMSecsSinceEpoch();
st.pausedPosSec = (now - st.startMs) / 1000.0f;
st.playing = false;
st.paused = true;
SoundManager::instance()->pausePcm(st.playbackId);
model->setData(index, 2, MsgVoicePlayStateRole);
return true;
}
if (st.paused) {
/* Paused → Resume */
st.paused = false;
st.playing = true;
qint64 now = QDateTime::currentMSecsSinceEpoch();
st.startMs = now - (qint64)(st.pausedPosSec * 1000.0);
st.displayFrac = (dur > 0) ? (st.pausedPosSec / dur) : 0;
st.displayElapsed = st.pausedPosSec;
SoundManager::instance()->resumePcm(st.playbackId);
s_voiceTimer->start();
model->setData(index, 1, MsgVoicePlayStateRole);
return true;
}
/* Stopped or different file → stop old, play new */
if (isOtherPlaying) {
for (auto it = s_voiceStates.begin(); it != s_voiceStates.end(); ++it) {
it->playing = false; it->paused = false;
}
VoicePlayer::stop();
}
st.loading = true;
QPersistentModelIndex persistent(index);
VoicePlayer::playOpusFile(filePath, this, [filePath, persistent](float actualDur, uint64_t playbackId) {
s_voiceStates[filePath].loading = false;
if (actualDur <= 0) return;
MessageDelegate::startAutoPlay(filePath, actualDur, playbackId);
if (persistent.isValid()) const_cast<QAbstractItemModel*>(persistent.model())->setData(persistent, 1, MsgVoicePlayStateRole);
});
s_voiceTimer->start();
return true;
}
QSize MessageDelegate::sizeHint(const QStyleOptionViewItem &option,
const QModelIndex &index) const {
Layout L = calcLayout(option, index);
return QSize(L.viewWidth, L.totalHeight);
}
static int measureTextHeight(const QFont &font, const QString &text, int width, const QString& compactLink = {}) {
if (text.isEmpty() || width <= 0) return 0;
if (!messageLinks(text).isEmpty()) {
QString display = text; display.replace('\n', QChar(0x2028));
QTextLayout layout(display, font);
return (int)std::ceil(layoutMessageText(layout, width, compactLink));
}
QRect r = QFontMetrics(font).boundingRect(QRect(0, 0, width, 100000),
Qt::AlignLeft | Qt::TextWordWrap, text);
return r.height();
}
static QString formatFileSize(qint64 bytes) {
if (bytes < 1024) return QCoreApplication::translate("ChatGui", "%1B").arg(bytes);
double kb = bytes / 1024.0;
if (kb < 1024.0) return QCoreApplication::translate("ChatGui", "%1KB").arg(kb, 0, 'f', kb < 10.0 ? 1 : 0);
double mb = kb / 1024.0;
if (mb < 1024.0) return QCoreApplication::translate("ChatGui", "%1MB").arg(mb, 0, 'f', mb < 10.0 ? 1 : 0);
double gb = mb / 1024.0;
return QCoreApplication::translate("ChatGui", "%1GB").arg(gb, 0, 'f', 1);
}
static QString formatBitrate(qint64 bytes, qint64 durMs) {
if (bytes <= 0 || durMs <= 0) return QString();
qint64 kbps = bytes * 8 / durMs;
if (kbps >= 1000) return QCoreApplication::translate("ChatGui", "%1Mbps").arg(kbps / 1000.0, 0, 'f', 1);
return QCoreApplication::translate("ChatGui", "%1kbps").arg(kbps);
}
static int calcMaxLineWidth(const QFont &font, const QString &text, int availW) {
int maxW = 0;
int pos = 0;
while (pos <= text.length()) {
int end = text.indexOf('\n', pos);
if (end < 0) end = text.length();
QString para = text.mid(pos, end - pos);
if (!para.isEmpty()) {
QTextLayout layout(para, font);
layout.beginLayout();
while (true) {
QTextLine line = layout.createLine();
if (!line.isValid()) break;
line.setLineWidth(availW);
line.setPosition(QPointF(0, 0));
maxW = qMax(maxW, (int)line.naturalTextWidth());
}
layout.endLayout();
}
pos = end + 1;
}
return maxW;
}
MessageDelegate::Layout MessageDelegate::calcLayout(
const QStyleOptionViewItem& option, const QModelIndex& index) const {
Layout layout = calcBodyLayout(option, index);
// Медиа и крупные emoji получают ту же цитату над своим содержимым.
if (index.data(MsgHasQuoteRole).toBool() && !layout.quoteRect.isValid()) {
const int line = QFontMetrics(option.font).height(), height = 2 * line + 2 * kQuotePadV + 2;
const QRect body = layout.emojiOnly ? layout.emojiArea : layout.bubbleRect;
const int width = qMin(qMax(200, body.width()), qMax(40, layout.viewWidth - 2 * kMarginH));
const int x = layout.isOutgoing ? body.right() - width + 1 : body.left();
layout.quoteRect = QRect(qMax(kMarginH, x), 0, width, height);
layout.quoteBarRect = QRect(layout.quoteRect.left(), 0, kQuoteBarW, height);
layout.quoteAuthorRect = QRect(layout.quoteRect.left() + kQuotePadH + kQuoteBarW, kQuotePadV,
width - 2 * kQuotePadH - kQuoteBarW, line);
layout.quoteTextRect = layout.quoteAuthorRect.translated(0, line + 2);
for (QRect* rect : {&layout.bubbleRect, &layout.avatarRect, &layout.authorRect, &layout.textRect, &layout.statusRect,
&layout.deliveryRect, &layout.emojiArea, &layout.voicePlayBtnRect, &layout.voiceWaveformRect,
&layout.voiceDurationRect, &layout.voiceTimeRect, &layout.mediaBarRect, &layout.progressBarRect,
&layout.videoThumbRect, &layout.videoPlayBtnRect, &layout.videoDurBadgeRect, &layout.imageThumbRect,
&layout.imageBadgeRect}) if (rect->isValid()) rect->translate(0, height + kQuoteGap);
layout.totalHeight += height + kQuoteGap;
}
const QString channel = index.data(MsgChannelIdRole).toString();
if (layout.emojiOnly || channel.isEmpty() || channel.startsWith("dm:")
|| index.data(MsgMessageKeyRole).toString().size() != 64 || layout.bubbleRect.isEmpty()) return layout;
QFont font = option.font; font.setPointSize(qMax(1, font.pointSize() - 1));
QFont timeFont = option.font; timeFont.setPointSize(qMax(1, timeFont.pointSize() - 2));
const int height = qMax(24, QFontMetrics(font).height() + 6), left = layout.bubbleRect.left() + kPadH;
layout.reactionTimeText = index.data(MsgEditedRole).toBool() ? QCoreApplication::translate("ChatGui", "edited ") : QString();
layout.reactionTimeText += index.data(MsgTimeRole).toString();
const int innerWidth = layout.bubbleRect.width() - 2 * kPadH;
const int timeWidth = qMin(QFontMetrics(timeFont).horizontalAdvance(layout.reactionTimeText) + 2, qMax(0, innerWidth - height - 6));
const int reactionsRight = layout.bubbleRect.right() - kPadH - timeWidth - 6;
int top;
if (layout.isVoice) top = layout.voiceDurationRect.bottom() + 4;
else if (layout.isVideo) top = layout.statusRect.bottom() + 4;
else if (layout.isImage) top = layout.bubbleRect.bottom() + 3;
else if (layout.isFile) top = qMax(layout.statusRect.bottom() + 4, layout.bubbleRect.bottom() - kPadBot);
else top = layout.statusRect.top();
if (layout.deliveryRect.isValid()) top = qMax(top, layout.deliveryRect.bottom() + 4);
int y = top, x = left + height + 4;
layout.reactionHeart = QRect(left, top, height, height);
for (const auto& value : index.data(MsgReactionsRole).toList()) {
const auto reaction = value.toMap(); font.setBold(reaction.value("mine").toBool());
const int width = qMin(reactionsRight - left + 1, 30 + QFontMetrics(font).horizontalAdvance(reaction.value("count").toString()));
if (x + width - 1 > reactionsRight) { x = left; y += height + 4; }
layout.reactionChips.append({QRect(x, y, width, height), reaction.value("id").toInt(),
reaction.value("emoji").toString(), reaction.value("count").toInt(), reaction.value("mine").toBool()});
x += width + 4;
}
layout.reactionTime = QRect(layout.bubbleRect.right() - kPadH - timeWidth + 1, y, timeWidth, height);
layout.bubbleRect.setBottom(y + height + kPadBot - 1);
layout.totalHeight = qMax(layout.bubbleRect.bottom() + 1, kAvatar) + kItemGap;
if (layout.avatarRect.isValid()) layout.avatarRect.moveTop(qMax(0, layout.bubbleRect.bottom() + 1 - kAvatar));
return layout;
}
MessageDelegate::Layout MessageDelegate::calcBodyLayout(
const QStyleOptionViewItem &option, const QModelIndex &index) const
{
Layout L{};
L.viewWidth = 300;
if (option.widget) {
auto *v = qobject_cast<const QAbstractItemView *>(option.widget);
if (v) L.viewWidth = v->viewport()->width();
}
if (L.viewWidth <= 0) L.viewWidth = 300;
L.narrow = L.viewWidth - 2 * kMarginH - kAvatar - kGap < 400;
bool hasQuote = index.data(MsgHasQuoteRole).toBool();
QString text = index.data(MsgTextRole).toString();
L.isOutgoing = index.data(MsgIsOutgoingRole).toBool();
/* Voice message detection */
QString contentType = index.data(MsgContentTypeRole).toString();
L.isVoice = (contentType == "audio/opus");
if (L.isVoice) {
L.voiceDuration = index.data(MsgVoiceDurationRole).toString();
int pbs = 16; // play button size
int wh = 25; // waveform height
int th = 14; // text line height
L.bubbleWidth = 220;
int barW = 3, barPad = 4;
int bubbleH = kPadTop + wh + 4 + th + kPadBot;
L.totalHeight = qMax(bubbleH, kAvatar) + kItemGap;
int contentEdge = L.isOutgoing
? L.viewWidth - (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap))
: (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap));
int bx = L.isOutgoing ? (contentEdge - L.bubbleWidth - barW - barPad) : contentEdge;
L.bubbleRect = QRect(bx, 0, L.bubbleWidth, bubbleH);
L.mediaBarRect = QRect(L.isOutgoing ? bx + L.bubbleWidth + 1 : bx - barW - 1, 0, barW, bubbleH);
int dlState = index.data(MsgFileDownloadStateRole).toInt();
int bd = index.data(MsgMediaBlocksDoneRole).toInt();
int nb = index.data(MsgMediaNumBlocksRole).toInt();
L.mediaBarColor = (dlState == 2) ? 2 : (dlState == 1 || (bd > 0 && nb > 0)) ? 1 : 0;
if (dlState == 1 && nb > 0) L.progressFrac = (float)bd / nb;
if (dlState == 1) { L.progressBarRect = QRect(bx, bubbleH - 3, L.bubbleWidth, 3); }
L.voicePlayBtnRect = QRect(bx + kPadH, kPadTop + 4, pbs, pbs);
int wx = bx + kPadH + pbs + 4;
int ww = L.bubbleWidth - 2*kPadH - pbs - 4;
L.voiceWaveformRect = QRect(wx, kPadTop + 1, ww, wh);
L.voiceDurationRect = QRect(wx, kPadTop + wh + 4, ww - 40, th);
L.voiceTimeRect = QRect(wx + ww - 40, kPadTop + wh + 4, 40, th);
if (!index.data(MsgDeliveryStateRole).toString().isEmpty()) {
L.deliveryRect = QRect(bx, bubbleH + 2, L.bubbleWidth, th);
L.totalHeight += th + 2;
}
if (!L.narrow) {
int avatarY = qMax(0, L.bubbleRect.bottom() + 1 - kAvatar);
if (!L.isOutgoing)
L.avatarRect = QRect(kMarginH, avatarY, kAvatar, kAvatar);
else
L.avatarRect = QRect(L.viewWidth - kMarginH - kAvatar, avatarY, kAvatar, kAvatar);
}
return L;
}
/* Video message detection */
L.isVideo = (contentType == "video/mp4");
if (L.isVideo) {
int barW = 3, barPad = 4;
int thW = 200, thH = 112; /* 16:9 превью */
int infoH = 16; /* строка под пузырём: res·size·bitrate + время */
L.bubbleWidth = thW;
L.totalHeight = qMax(thH, kAvatar) + infoH + kItemGap;
int contentEdge = L.isOutgoing
? L.viewWidth - (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap))
: (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap));
int bx = L.isOutgoing ? (contentEdge - thW - barW - barPad) : contentEdge;
L.bubbleRect = QRect(bx, 0, thW, thH);
L.videoThumbRect = L.bubbleRect;
L.mediaBarRect = QRect(L.isOutgoing ? bx + thW + 1 : bx - barW - 1, 0, barW, thH);
L.statusRect = QRect(bx, thH + 2, thW, infoH - 2);
if (!index.data(MsgDeliveryStateRole).toString().isEmpty()) {
L.deliveryRect = QRect(bx, thH + infoH + 2, thW, infoH - 2);
L.totalHeight += infoH;
}
int dlState = index.data(MsgFileDownloadStateRole).toInt();
int bd = index.data(MsgMediaBlocksDoneRole).toInt();
int nb = index.data(MsgMediaNumBlocksRole).toInt();
L.mediaBarColor = (dlState == 2) ? 2 : (dlState == 1 || (bd > 0 && nb > 0)) ? 1 : 0;
if (dlState == 1 && nb > 0) L.progressFrac = (float)bd / nb;
if (dlState == 1) { L.progressBarRect = QRect(bx, thH - 3, thW, 3); }
L.videoPlayBtnRect = QRect(bx + thW / 2 - 24, thH / 2 - 24, 48, 48);
L.videoDurBadgeRect = QRect(bx + thW - 66, thH - 26, 58, 18);
if (!L.narrow) {
int avatarY = qMax(0, L.bubbleRect.bottom() + 1 - kAvatar);
if (!L.isOutgoing)
L.avatarRect = QRect(kMarginH, avatarY, kAvatar, kAvatar);
else
L.avatarRect = QRect(L.viewWidth - kMarginH - kAvatar, avatarY, kAvatar, kAvatar);
}
return L;
}
/* Image message detection */
L.isImage = contentType.startsWith("image/");
if (L.isImage) {
int barW = 3, barPad = 4;
int thW = 220, thH = 160;
L.bubbleWidth = thW;
L.totalHeight = qMax(thH, kAvatar) + kItemGap;
int contentEdge = L.isOutgoing
? L.viewWidth - (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap))
: (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap));
int bx = L.isOutgoing ? (contentEdge - thW - barW - barPad) : contentEdge;
L.bubbleRect = QRect(bx, 0, thW, thH);
L.imageThumbRect = L.bubbleRect;
L.mediaBarRect = QRect(L.isOutgoing ? bx + thW + 1 : bx - barW - 1, 0, barW, thH);
int dlState = index.data(MsgFileDownloadStateRole).toInt();
int bd = index.data(MsgMediaBlocksDoneRole).toInt();
int nb = index.data(MsgMediaNumBlocksRole).toInt();
L.mediaBarColor = (dlState == 2) ? 2 : (dlState == 1 || (bd > 0 && nb > 0)) ? 1 : 0;
if (dlState == 1 && nb > 0) L.progressFrac = (float)bd / nb;
if (dlState == 1) { L.progressBarRect = QRect(bx, thH - 3, thW, 3); }
L.imageBadgeRect = QRect(bx + thW - 150, thH - 26, 142, 18);
if (!L.narrow) {
int avatarY = qMax(0, L.bubbleRect.bottom() + 1 - kAvatar);
if (!L.isOutgoing)
L.avatarRect = QRect(kMarginH, avatarY, kAvatar, kAvatar);
else
L.avatarRect = QRect(L.viewWidth - kMarginH - kAvatar, avatarY, kAvatar, kAvatar);
}
return L;
}
/* File attachment detection */
L.isFile = (contentType == "application/octet-stream");
if (L.isFile) {
QFontMetrics fm(option.font);
int th = fm.height();
int ih = 24; /* icon height */
L.bubbleWidth = 230;
int barW = 3, barPad = 4;
int bubbleH = kPadTop + ih + 2 + th + kPadBot;
L.totalHeight = qMax(bubbleH, kAvatar) + kItemGap;
int contentEdge = L.isOutgoing
? L.viewWidth - (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap))
: (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap));
int bx = L.isOutgoing ? (contentEdge - L.bubbleWidth - barW - barPad) : contentEdge;
L.bubbleRect = QRect(bx, 0, L.bubbleWidth, bubbleH);
L.mediaBarRect = QRect(L.isOutgoing ? bx + L.bubbleWidth + 1 : bx - barW - 1, 0, barW, bubbleH);
int dlState = index.data(MsgFileDownloadStateRole).toInt();
int bd = index.data(MsgMediaBlocksDoneRole).toInt();
int nb = index.data(MsgMediaNumBlocksRole).toInt();
L.mediaBarColor = (dlState == 2) ? 2 : (dlState == 1 || (bd > 0 && nb > 0)) ? 1 : 0;
if (dlState == 1 && nb > 0) L.progressFrac = (float)bd / nb;
if (dlState == 1) { L.progressBarRect = QRect(bx, bubbleH - 3, L.bubbleWidth, 3); }
L.textRect = QRect(bx + kPadH + ih + 8, kPadTop, L.bubbleWidth - 2*kPadH - ih - 8, th);
L.statusRect = QRect(bx + kPadH + ih + 8 - 48, kPadTop + th + 2, L.bubbleWidth - 2*kPadH - ih - 8 + 48 - 40, th);
if (!L.narrow) {
int avatarY = qMax(0, L.bubbleRect.bottom() + 1 - kAvatar);
if (!L.isOutgoing)
L.avatarRect = QRect(kMarginH, avatarY, kAvatar, kAvatar);
else
L.avatarRect = QRect(L.viewWidth - kMarginH - kAvatar, avatarY, kAvatar, kAvatar);
}
return L;
}
L.emojiCount = detectEmojiOnly(text);
L.emojiOnly = (L.emojiCount >= 1);
if (L.emojiOnly) {
QFontMetrics fm(option.font);
int lineH = fm.lineSpacing();
int availW = L.viewWidth - kMarginH - kAvatar - kGap - kMarginH;
bool narrow = (availW < 400);
int contentEdge = !L.isOutgoing
? (narrow ? kMarginH : (kMarginH + kAvatar + kGap))
: L.viewWidth - (narrow ? kMarginH : (kMarginH + kAvatar + kGap));
int eSize = (L.emojiCount == 1) ? lineH * 4 : lineH * 2;
int emojiGap = lineH / 2;
int totalW = L.emojiCount * eSize + (L.emojiCount - 1) * emojiGap;
int x = !L.isOutgoing
? (contentEdge + kPadH)
: (contentEdge - kPadH - totalW);
L.emojiArea = QRect(x, kGap * 2, totalW, eSize);
L.totalHeight = eSize + kGap * 2 + kItemGap;
L.bubbleRect = QRect();
return L;
}
QString qText = hasQuote ? index.data(MsgQuoteTextRole).toString() : QString();
int availW = L.viewWidth - kMarginH - kAvatar - kGap - kMarginH;
L.narrow = (availW < 400);
if (L.narrow) availW = L.viewWidth - 2 * kMarginH;
int textLimitW = availW - 2 * kPadH;
int limitW = text.contains(' ') ? qMin(textLimitW, 700)
: qMin(textLimitW, 1200);
QFontMetrics fm(option.font);
int contentW = calcMaxLineWidth(option.font, text, limitW);
if (!L.isOutgoing) {
QString author = index.data(MsgAuthorRole).toString();
QFont boldFm = option.font;
boldFm.setBold(true);
int authorW = QFontMetrics(boldFm).horizontalAdvance(author);
contentW = qMax(contentW, authorW);
}
if (hasQuote) {
QString qAuthor = index.data(MsgQuoteAuthorRole).toString();
QFont boldFm = option.font;
boldFm.setBold(true);
int qAuthorW = QFontMetrics(boldFm).horizontalAdvance(qAuthor);
contentW = qMax(contentW, qAuthorW + kQuoteBarW + kQuotePadH + 4);
int qTextW = qMin(contentW + 8, limitW);
int qWidth = calcMaxLineWidth(option.font, qText, qTextW);
contentW = qMax(contentW, qWidth + kQuoteBarW + kQuotePadH + 4);
}
int textW = qMax(200 - 2 * kPadH, contentW);
textW = qMin(textW, textLimitW);
int bubbleW = textW + 2 * kPadH;
L.bubbleWidth = bubbleW;
int qH = 0;
if (hasQuote) {
int qTextH = fm.height();
int qOneLineH = fm.height();
qH = kQuotePadV + qOneLineH + (qText.isEmpty() ? 0 : 2 + qTextH) + kQuotePadV;
}
int textH = measureTextHeight(option.font, text, textW, index.data(MsgInviteLinkRole).toString());
int authorH = fm.height();
int bubbleH = L.isOutgoing
? kPadTop + (hasQuote ? qH + kQuoteGap : 0) + textH + kStatusH + kPadBot
: kPadTop + authorH + 2 + (hasQuote ? qH + kQuoteGap : 0) + textH + kStatusH + kPadBot;
L.totalHeight = qMax(bubbleH, kAvatar) + kItemGap;
int contentEdge = L.isOutgoing
? L.viewWidth - (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap))
: (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap));
if (!L.isOutgoing)
L.bubbleRect = QRect(contentEdge, 0, bubbleW, bubbleH);
else
L.bubbleRect = QRect(contentEdge - bubbleW, 0, bubbleW, bubbleH);
int contentTop = kPadTop;
if (!L.isOutgoing) {
L.authorRect = QRect(contentEdge + kPadH, contentTop, textW, authorH);
contentTop += authorH + 2;
}
if (hasQuote) {
int qX = !L.isOutgoing
? (contentEdge + kPadH)
: (contentEdge - kPadH - (bubbleW - 2 * kPadH));
L.quoteRect = QRect(qX, contentTop, bubbleW - 2 * kPadH, qH);
if (!L.isOutgoing)
L.quoteBarRect = QRect(L.quoteRect.left(), L.quoteRect.top(), kQuoteBarW, qH);
else
L.quoteBarRect = QRect(L.quoteRect.right() - kQuoteBarW, L.quoteRect.top(), kQuoteBarW, qH);
int tx = !L.isOutgoing
? (L.quoteBarRect.right() + kQuotePadH)
: (L.quoteRect.left() + kQuotePadH);
L.quoteAuthorRect = QRect(tx, L.quoteRect.top() + kQuotePadV,
L.quoteRect.width() - kQuoteBarW - kQuotePadH,
QFontMetrics(option.font).height());
L.quoteTextRect = QRect(tx,
L.quoteAuthorRect.bottom() + 2,
L.quoteAuthorRect.width(),
qH - kQuotePadV - QFontMetrics(option.font).height() - 2 - kQuotePadV);
contentTop += qH + kQuoteGap;
}
int txRect = !L.isOutgoing
? (contentEdge + kPadH)
: (contentEdge - kPadH - textW);
L.textRect = QRect(txRect, contentTop, textW, textH);
L.statusRect = QRect(txRect, contentTop + textH + 2, textW, kStatusH);
if (!L.narrow) {
int avatarY = qMax(0, L.bubbleRect.bottom() + 1 - kAvatar);
if (!L.isOutgoing)
L.avatarRect = QRect(kMarginH, avatarY, kAvatar, kAvatar);
else
L.avatarRect = QRect(L.viewWidth - kMarginH - kAvatar, avatarY, kAvatar, kAvatar);
}
return L;
}
static int paraLayoutHeight(const QFont &font, const QString &text,
int textWidth, int globalChar, qreal globalY,
int yTop, int yBot, int xTop, int xBot,
bool &foundStart, bool &foundEnd,
int &startChar, int &endChar) {
if (text.isEmpty()) return QFontMetrics(font).lineSpacing();
QTextLayout layout(text, font);
layout.beginLayout();
qreal localY = 0;
while (true) {
QTextLine line = layout.createLine();
if (!line.isValid()) break;
line.setLineWidth(textWidth);
line.setPosition(QPointF(0, globalY + localY));
localY += line.height();
if (!foundStart && globalY + localY > yTop) {
startChar = globalChar + line.xToCursor(xTop);
foundStart = true;
}
if (!foundEnd && globalY >= yBot) break;
if (!foundEnd && globalY + localY >= yBot) {
endChar = globalChar + line.xToCursor(xBot);
foundEnd = true;
}
}
layout.endLayout();
return (int)ceil(localY);
}
static void calcTextSelection(const QFont &font, const QString &text,
int textWidth, const QPoint &p1, const QPoint &p2,
int &startChar, int &endChar) {
startChar = 0;
endChar = 0;
if (text.isEmpty() || textWidth <= 0) return;
int yTop = qMin(p1.y(), p2.y());
int yBot = qMax(p1.y(), p2.y());
int xTop = (p1.y() < p2.y()) ? p1.x() : p2.x();
int xBot = (p1.y() < p2.y()) ? p2.x() : p1.x();
int globalChar = 0;
qreal globalY = 0;
bool foundStart = false, foundEnd = false;
int paraStart = 0;
while (paraStart <= text.length()) {
int paraEnd = text.indexOf('\n', paraStart);
if (paraEnd < 0) paraEnd = text.length();
QString para = text.mid(paraStart, paraEnd - paraStart);
int paraH = paraLayoutHeight(font, para, textWidth,
globalChar, globalY,
yTop, yBot, xTop, xBot,
foundStart, foundEnd,
startChar, endChar);
globalChar += para.length();
globalY += paraH;
if (paraEnd < text.length())
globalChar++;
paraStart = paraEnd + 1;
if (foundStart && foundEnd) break;
}
if (!foundStart) startChar = 0;
if (!foundEnd) endChar = text.length();
if (startChar == endChar && endChar < text.length()) endChar = startChar + 1;
if (startChar > endChar) qSwap(startChar, endChar);
}
static void drawTextSelectionHighlight(QPainter *p, const QFont &font,
const QString &text, int textWidth,
const QRect &textRect,
const QColor &selColor,
int startChar, int endChar) {
if (startChar >= endChar) return;
int globalChar = 0;
qreal globalY = 0;
int paraStart = 0;
while (paraStart <= text.length()) {
int paraEnd = text.indexOf('\n', paraStart);
if (paraEnd < 0) paraEnd = text.length();
QString para = text.mid(paraStart, paraEnd - paraStart);
if (para.isEmpty()) {
globalY += QFontMetrics(font).lineSpacing();
} else {
QTextLayout layout(para, font);
layout.beginLayout();
qreal localY = 0;
while (true) {
QTextLine line = layout.createLine();
if (!line.isValid()) break;
line.setLineWidth(textWidth);
line.setPosition(QPointF(0, globalY + localY));
int ls = globalChar + line.textStart();
int le = ls + line.textLength();
localY += line.height();
if (ls >= endChar || le <= startChar) continue;
int cs = qMax(startChar, ls);
int ce = qMin(endChar, le);
qreal x1 = line.cursorToX(cs - globalChar);
qreal x2 = line.cursorToX(ce - globalChar);
QRectF hr(textRect.left() + x1,
textRect.top() + globalY + line.y(),
x2 - x1, line.height());
p->fillRect(hr, selColor);
}
layout.endLayout();
globalY += localY;
}
globalChar += para.length();
if (paraEnd < text.length())
globalChar++;
paraStart = paraEnd + 1;
}
}
static void drawAnimatedEmojiOverlay(QPainter *p, const QFont &font,
const QString &text, int textWidth,
const QRect &textRect) {
if (g_animatedEmojiMap.isEmpty()) return;
int globalChar = 0;
qreal globalY = 0;
int paraStart = 0;
while (paraStart <= text.length()) {
int paraEnd = text.indexOf('\n', paraStart);
if (paraEnd < 0) paraEnd = text.length();
QString para = text.mid(paraStart, paraEnd - paraStart);
if (!para.isEmpty()) {
QTextLayout layout(para, font);
if (!messageLinks(text).isEmpty()) {
QTextOption wrap; wrap.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); layout.setTextOption(wrap);
}
layout.beginLayout();
qreal localY = 0;
while (true) {
QTextLine line = layout.createLine();
if (!line.isValid()) break;
line.setLineWidth((qreal)textWidth);
line.setPosition(QPointF(0, globalY + localY));
localY += line.height();
int ls = globalChar + line.textStart();
int le = ls + line.textLength();
for (auto it = g_animatedEmojiMap.begin(); it != g_animatedEmojiMap.end(); ++it) {
const QString &unicode = it.key();
int pos = text.indexOf(unicode, ls);
while (pos >= 0 && pos + unicode.length() <= le) {
int linePos = pos - globalChar;
int cp1 = linePos, cp2 = linePos + unicode.length();
qreal x1 = line.cursorToX(&cp1);
qreal x2 = line.cursorToX(&cp2);
LottieIcon *icon = AnimTimer::instance()->icon(it.value());
if (icon && icon->isValid()) {
int eW = qMax(1, (int)((x2 - x1) * 1.3));
int eH = qMax(1, (int)(line.height() * 1.3));
QImage frame = icon->renderFrame(icon->currentFrame(), QSize(eW, eH));
qreal cx = x1 + (x2 - x1 - eW) * 0.5;
qreal cy = (line.height() - eH) * 0.5;
p->drawImage(QPointF(textRect.left() + cx, textRect.top() + line.y() + cy), frame);
}
pos = text.indexOf(unicode, pos + unicode.length());
}
}
}
layout.endLayout();
globalY += localY;
} else {
globalY += QFontMetrics(font).lineSpacing();
}
globalChar += para.length();
if (paraEnd < text.length()) globalChar++;
paraStart = paraEnd + 1;
}
}
void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
const QModelIndex &index) const {
const Layout layout = calcLayout(option, index);
paintBody(painter, option, index, layout);
painter->save(); painter->translate(option.rect.topLeft());
painter->setClipRect(QRect(QPoint(0, 0), option.rect.size()));
if (layout.quoteRect.isValid()) drawQuote(painter, layout, index, option);
if (layout.reactionHeart.isValid()) drawReactions(painter, layout, option);
if (index.data(MsgHighlightRole).toBool()) {
QColor color = option.palette.highlight().color(); color.setAlpha(55);
painter->fillRect(QRect(QPoint(0, 0), option.rect.size()), color);
}
painter->restore();
}
void MessageDelegate::paintBody(QPainter* painter, const QStyleOptionViewItem& option,
const QModelIndex& index, const Layout& L) const {
painter->save();
painter->translate(option.rect.topLeft());
painter->setClipRect(QRect(QPoint(0, 0), option.rect.size()));
if (L.deliveryRect.isValid()) {
QFont font = option.font;
font.setPointSize(qMax(1, font.pointSize() - 2));
painter->setFont(font);
painter->setPen(option.palette.text().color().lighter(150));
const auto text = QFontMetrics(font).elidedText(index.data(MsgDeliveryStateRole).toString(), Qt::ElideRight, L.deliveryRect.width());
painter->drawText(L.deliveryRect, Qt::AlignRight | Qt::AlignVCenter, text);
}
if (L.emojiOnly) {
QString msgText = index.data(MsgTextRole).toString();
QString trimmed = msgText.trimmed();
QFontMetrics fm(option.font);
int lineH = fm.lineSpacing();
int eSize = (L.emojiCount == 1) ? lineH * 4 : lineH * 2;
int x = L.emojiArea.left();
int y = L.emojiArea.top();
int pos = 0;
while (pos < trimmed.length()) {
if (trimmed.at(pos).isSpace()) { ++pos; continue; }
QString emojiStr;
bool found = false;
for (const auto &cat : builtinEmojiSet()) {
for (const auto &e : cat.items) {
if (e.unicode.length() > trimmed.length() - pos) continue;
if (QStringView(trimmed).mid(pos, e.unicode.length()) == e.unicode) {
emojiStr = e.unicode;
pos += e.unicode.length();
found = true;
break;
}
}
if (found) break;
}
if (emojiStr.isEmpty()) { ++pos; continue; }
bool animated = g_animatedEmojiMap.contains(emojiStr);
if (animated) {
LottieIcon *icon = AnimTimer::instance()->icon(g_animatedEmojiMap[emojiStr]);
if (icon && icon->isValid()) {
QImage frame = icon->renderFrame(icon->currentFrame(), QSize(eSize, eSize));
painter->drawImage(x, y, frame);
} else {
QFont big = option.font;
big.setPointSize((int)(big.pointSize() * (L.emojiCount == 1 ? 4.0 : 2.0)));
painter->setFont(big);
painter->setPen(option.palette.text().color());
painter->drawText(QRect(x, y, eSize, eSize), Qt::AlignCenter, emojiStr);
}
} else {
QFont big = option.font;
big.setPointSize((int)(big.pointSize() * (L.emojiCount == 1 ? 4.0 : 2.0)));
painter->setFont(big);
painter->setPen(option.palette.text().color());
painter->drawText(QRect(x, y, eSize, eSize), Qt::AlignCenter, emojiStr);
}
x += eSize + lineH / 2;
}
painter->restore();
return;
}
if (L.isVoice) {
if (option.widget) s_voiceView = (QAbstractItemView*)option.widget;
drawBubble(painter, L, option);
if (!L.narrow) drawAvatar(painter, L, index);
QString filePath = index.data(MsgVoiceFileRole).toString();
VoicePlayState copySt;
VoicePlayState* st = &copySt;
if (!filePath.isEmpty() && s_voiceStates.contains(filePath)) {
st = &s_voiceStates[filePath];
} else {
int stateVal = index.data(MsgVoicePlayStateRole).toInt();
copySt.playing = (stateVal == 1);
st = &copySt;
}
bool isPlaying = st->playing;
bool isPaused = st->paused;
/* compute elapsed/frac for progress */
float elapsed = 0, targetFrac = 0, durSec = st->durationSec;
if (durSec <= 0) {
QString ds = index.data(MsgVoiceDurationRole).toString();
ds.remove('s'); durSec = ds.toFloat();
}
if (isPaused) {
elapsed = st->pausedPosSec;
} else if (isPlaying) {
SoundManager* sm = SoundManager::instance();
if (sm->isPcmPlaying(st->playbackId)) {
int sr = sm->pcmSampleRate(st->playbackId);
if (sr > 0) elapsed = (float)((double)sm->pcmCursor(st->playbackId) / sr);
}
if (elapsed <= 0) {
qint64 now = QDateTime::currentMSecsSinceEpoch();
elapsed = (now - st->startMs) / 1000.0f;
}
if (elapsed > durSec) elapsed = durSec;
}
targetFrac = (durSec > 0) ? (elapsed / durSec) : 0;
if (targetFrac < 0) targetFrac = 0; if (targetFrac > 1) targetFrac = 1;
st->displayFrac += (targetFrac - st->displayFrac) * 0.15f;
st->displayElapsed += (elapsed - st->displayElapsed) * 0.15f;
float frac = st->displayFrac;
QRectF playR = L.voicePlayBtnRect;
/* Play/Pause icon (small — 10px triangle) */
painter->setPen(Qt::NoPen);
painter->setBrush(QColor(0x33, 0x90, 0xEC));
if (isPlaying || isPaused) {
qreal bw = playR.width() / 5.0;
qreal gap = playR.width() / 8.0;
qreal top = playR.center().y() - playR.height() / 3.0;
qreal bot = playR.center().y() + playR.height() / 3.0;
painter->drawRoundedRect(QRectF(playR.center().x() - gap - bw, top, bw, bot - top), 1, 1);
painter->drawRoundedRect(QRectF(playR.center().x() + gap, top, bw, bot - top), 1, 1);
} else {
QPainterPath path;
qreal cx = playR.center().x() - playR.width() / 6.0;
qreal cy = playR.center().y();
qreal hs = playR.height() / 2.5;
path.moveTo(cx, cy - hs);
path.lineTo(cx + hs * 1.5, cy);
path.lineTo(cx, cy + hs);
path.closeSubpath();
painter->drawPath(path);
}
/* Waveform (25px) */
QList<QVariant> wf = index.data(MsgVoiceWaveformRole).toList();
int wfN = wf.size();
QRect wfR = L.voiceWaveformRect;
if (wfN > 0) {
float barW = (float)wfR.width() / wfN;
int ph = wfR.height() - 3;
for (int i = 0; i < wfN; i++) {
float logV = wf[i].toFloat(); /* already 0..1 normalized */
int barH = 2 + (int)(logV * (ph - 2));
int x = wfR.left() + (int)(i * barW);
int y = wfR.top() + ph - barH;
int bw = qMax(2, (int)(barW * 0.8f));
bool before = wfN > 0 && (float)i / wfN <= frac;
QColor played(0x33, 0x90, 0xEC);
QColor unplayed(0x33, 0x90, 0xEC, 80);
QColor idle(0x70, 0x70, 0x78, 130);
painter->setBrush((isPlaying || isPaused) ? (before ? played : unplayed) : idle);
painter->drawRect(QRect(x, y, bw, barH));
}
}
/* Progress bar (3px, at bottom of waveform area) */
int ph = 3;
int pbY = wfR.bottom() - ph;
painter->setBrush(QColor(0x44, 0x44, 0x44));
painter->drawRoundedRect(QRect(wfR.left(), pbY, wfR.width(), ph), 2, 2);
if (frac > 0) {
painter->setBrush(QColor(0x33, 0x90, 0xEC));
painter->drawRoundedRect(QRect(wfR.left(), pbY, (int)(wfR.width() * frac), ph), 2, 2);
}
/* Duration + elapsed on bottom line */
QFont timeFont = option.font;
timeFont.setPointSize(timeFont.pointSize() - 2);
painter->setFont(timeFont);
painter->setPen(option.palette.text().color().lighter(150));
int eSec = (int)st->displayElapsed, dSec = (int)durSec;
QString durText = QString("%1:%2 / %3:%4")
.arg(eSec / 60).arg(eSec % 60, 2, 10, QChar('0'))
.arg(dSec / 60).arg(dSec % 60, 2, 10, QChar('0'));
painter->drawText(L.voiceDurationRect, Qt::AlignLeft | Qt::AlignVCenter, durText);
/* Timestamp */
QFont tsFont = option.font;
tsFont.setPointSize(tsFont.pointSize() - 1);
painter->setFont(tsFont);
if (!L.reactionTime.isValid()) painter->drawText(L.voiceTimeRect, Qt::AlignRight | Qt::AlignVCenter,
index.data(MsgTimeRole).toString());
/* media bar (left edge, 3px) */
{ QColor barC = L.mediaBarColor == 2 ? QColor(0x40, 0xC0, 0x40) : L.mediaBarColor == 1 ? QColor(0xF0, 0xC0, 0x40) : QColor(0x80, 0x80, 0x80);
painter->setPen(Qt::NoPen); painter->setBrush(barC); painter->drawRect(L.mediaBarRect); }
/* progress bar (bottom edge, 3px) */
if (L.progressBarRect.isValid()) {
painter->setBrush(QColor(0x44, 0x44, 0x44));
painter->drawRoundedRect(L.progressBarRect, 2, 2);
int pw = (int)(L.progressBarRect.width() * L.progressFrac);
if (pw > 0) { painter->setBrush(QColor(0x33, 0x90, 0xEC)); painter->drawRoundedRect(QRect(L.progressBarRect.left(), L.progressBarRect.top(), pw, 3), 2, 2); }
}
painter->restore();
return;
}
if (L.isVideo) {
drawBubble(painter, L, option);
if (!L.narrow) drawAvatar(painter, L, index);
QString filePath = index.data(MsgVoiceFileRole).toString();
int dlState = index.data(MsgFileDownloadStateRole).toInt();
QString thumbPath = filePath + ".jpg";
painter->save();
painter->setRenderHint(QPainter::Antialiasing);
QPainterPath clip;
clip.addRoundedRect(L.videoThumbRect, kRadius, kRadius);
painter->setClipPath(clip);
QPixmap thumb;
if (!thumbPath.isEmpty() && QFile::exists(thumbPath))
thumb.load(thumbPath);
if (!thumb.isNull()) {
QPixmap scaled = thumb.scaled(L.videoThumbRect.size(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation);
int dx = (scaled.width() - L.videoThumbRect.width()) / 2;
int dy = (scaled.height() - L.videoThumbRect.height()) / 2;
painter->drawPixmap(L.videoThumbRect.topLeft(), scaled, QRect(dx, dy, L.videoThumbRect.width(), L.videoThumbRect.height()));
} else {
painter->fillRect(L.videoThumbRect, QColor(30, 30, 35));
QFont iconFont = option.font;
iconFont.setPointSize(iconFont.pointSize() + 10);
painter->setFont(iconFont);
painter->setPen(QColor(150, 150, 150));
painter->drawText(L.videoThumbRect, Qt::AlignCenter, QString::fromUtf8("\xF0\x9F\x8E\xAC"));
}
/* play-оверлей */
if (dlState == 2) {
painter->setPen(Qt::NoPen);
painter->setBrush(QColor(0, 0, 0, 110));
painter->drawEllipse(L.videoPlayBtnRect);
QPainterPath tri;
int cx = L.videoPlayBtnRect.center().x() - L.videoPlayBtnRect.width() / 12;
int cy = L.videoPlayBtnRect.center().y();
int hs = L.videoPlayBtnRect.height() / 4;
tri.moveTo(cx, cy - hs);
tri.lineTo(cx + hs * 2, cy);
tri.lineTo(cx, cy + hs);
tri.closeSubpath();
painter->setBrush(Qt::white);
painter->drawPath(tri);
}
/* бейдж: длительность (m:ss) */
QString durStr = index.data(MsgVoiceDurationRole).toString();
if (!durStr.isEmpty()) {
QFont durFont = option.font;
durFont.setPointSize(durFont.pointSize() - 1);
painter->setFont(durFont);
painter->setPen(Qt::NoPen);
painter->setBrush(QColor(0, 0, 0, 160));
painter->drawRoundedRect(L.videoDurBadgeRect, 5, 5);
painter->setPen(Qt::white);
painter->drawText(L.videoDurBadgeRect, Qt::AlignCenter, durStr);
}
painter->restore();
/* progress overlay (скачивание) */
if (dlState == 1) {
painter->setBrush(QColor(0, 0, 0, 140));
painter->drawRect(L.videoThumbRect);
QFont pfont = option.font;
pfont.setPointSize(pfont.pointSize() + 2);
painter->setFont(pfont);
painter->setPen(Qt::white);
painter->drawText(L.videoThumbRect, Qt::AlignCenter,
QStringLiteral("%1%").arg((int)(L.progressFrac * 100)));
}
/* media bar (левая кромка) */
{ QColor barC = L.mediaBarColor == 2 ? QColor(0x40, 0xC0, 0x40) : L.mediaBarColor == 1 ? QColor(0xF0, 0xC0, 0x40) : QColor(0x80, 0x80, 0x80);
painter->setPen(Qt::NoPen); painter->setBrush(barC); painter->drawRect(L.mediaBarRect); }
if (L.progressBarRect.isValid()) {
painter->setBrush(QColor(0x44, 0x44, 0x44));
painter->drawRoundedRect(L.progressBarRect, 2, 2);
int pw = (int)(L.progressBarRect.width() * L.progressFrac);
if (pw > 0) { painter->setBrush(QColor(0x33, 0x90, 0xEC)); painter->drawRoundedRect(QRect(L.progressBarRect.left(), L.progressBarRect.top(), pw, 3), 2, 2); }
}
/* строка под пузырём: res · size · bitrate + время */
{
qint64 fsz = index.data(MsgFileSizeRole).toLongLong();
qint64 durMs = index.data(MsgVideoDurationMsRole).toLongLong();
int vw = index.data(MsgVideoWidthRole).toInt();
int vh = index.data(MsgVideoHeightRole).toInt();
QStringList info;
if (vw > 0 && vh > 0) info << QString("%1×%2").arg(vw).arg(vh);
QString sz = formatFileSize(fsz);
if (!sz.isEmpty()) info << sz;
QString br = formatBitrate(fsz, durMs);
if (!br.isEmpty()) info << br;
QFont smallFont = option.font;
smallFont.setPointSize(smallFont.pointSize() - 2);
painter->setFont(smallFont);
painter->setPen(QColor(0x80, 0x80, 0x80));
if (!info.isEmpty())
painter->drawText(L.statusRect, Qt::AlignLeft | Qt::AlignVCenter, info.join(" \xC2\xB7 "));
if (!L.reactionTime.isValid()) painter->drawText(L.statusRect, Qt::AlignRight | Qt::AlignVCenter,
index.data(MsgTimeRole).toString());
}
painter->restore();
return;
}
if (L.isImage) {
drawBubble(painter, L, option);
if (!L.narrow) drawAvatar(painter, L, index);
int dlState = index.data(MsgFileDownloadStateRole).toInt();
QString filePath = index.data(MsgVoiceFileRole).toString();
painter->save();
painter->setRenderHint(QPainter::Antialiasing);
QPainterPath clip;
clip.addRoundedRect(L.imageThumbRect, kRadius, kRadius);
painter->setClipPath(clip);
QPixmap img;
if (!filePath.isEmpty() && QFile::exists(filePath)) {
QImageReader reader(filePath);
reader.setAutoTransform(true);
reader.setScaledSize(L.imageThumbRect.size() * 2);
QImage im = reader.read();
if (!im.isNull()) img = QPixmap::fromImage(im);
}
if (!img.isNull()) {
painter->fillRect(L.imageThumbRect, QColor(20, 20, 24));
QPixmap scaled = img.scaled(L.imageThumbRect.size(), Qt::KeepAspectRatio, Qt::SmoothTransformation);
int dx = (L.imageThumbRect.width() - scaled.width()) / 2;
int dy = (L.imageThumbRect.height() - scaled.height()) / 2;
painter->drawPixmap(L.imageThumbRect.left() + dx, L.imageThumbRect.top() + dy, scaled);
} else {
painter->fillRect(L.imageThumbRect, QColor(30, 30, 35));
QFont iconFont = option.font;
iconFont.setPointSize(iconFont.pointSize() + 10);
painter->setFont(iconFont);
painter->setPen(QColor(150, 150, 150));
painter->drawText(L.imageThumbRect, Qt::AlignCenter, QString::fromUtf8("\xF0\x9F\x93\xB7"));
}
/* бейдж: разрешение · размер */
qint64 fsz = index.data(MsgFileSizeRole).toLongLong();
int iw = index.data(MsgImageWidthRole).toInt();
int ih = index.data(MsgImageHeightRole).toInt();
QString res = (iw > 0 && ih > 0) ? QString("%1×%2").arg(iw).arg(ih) : QString();
QString sizeStr = formatFileSize(fsz);
QString badge = res.isEmpty() ? sizeStr : res + " \xC2\xB7 " + sizeStr;
if (!badge.isEmpty()) {
QFont durFont = option.font;
durFont.setPointSize(durFont.pointSize() - 1);
painter->setFont(durFont);
painter->setPen(Qt::NoPen);
painter->setBrush(QColor(0, 0, 0, 160));
painter->drawRoundedRect(L.imageBadgeRect, 5, 5);
painter->setPen(Qt::white);
painter->drawText(L.imageBadgeRect, Qt::AlignCenter, badge);
}
painter->restore();
/* progress overlay (скачивание) */
if (dlState == 1) {
painter->setBrush(QColor(0, 0, 0, 140));
painter->drawRect(L.imageThumbRect);
QFont pfont = option.font;
pfont.setPointSize(pfont.pointSize() + 2);
painter->setFont(pfont);
painter->setPen(Qt::white);
painter->drawText(L.imageThumbRect, Qt::AlignCenter,
QStringLiteral("%1%").arg((int)(L.progressFrac * 100)));
}
/* media bar (левая кромка) */
{ QColor barC = L.mediaBarColor == 2 ? QColor(0x40, 0xC0, 0x40) : L.mediaBarColor == 1 ? QColor(0xF0, 0xC0, 0x40) : QColor(0x80, 0x80, 0x80);
painter->setPen(Qt::NoPen); painter->setBrush(barC); painter->drawRect(L.mediaBarRect); }
if (L.progressBarRect.isValid()) {
painter->setBrush(QColor(0x44, 0x44, 0x44));
painter->drawRoundedRect(L.progressBarRect, 2, 2);
int pw = (int)(L.progressBarRect.width() * L.progressFrac);
if (pw > 0) { painter->setBrush(QColor(0x33, 0x90, 0xEC)); painter->drawRoundedRect(QRect(L.progressBarRect.left(), L.progressBarRect.top(), pw, 3), 2, 2); }
}
painter->restore();
return;
}
if (L.isFile) {
drawBubble(painter, L, option);
if (!L.narrow) drawAvatar(painter, L, index);
int dlState = index.data(MsgFileDownloadStateRole).toInt();
/* File icon based on download state: 0=not_started, 1=downloading, 2=downloaded */
QFont iconFont = option.font;
iconFont.setPointSize(iconFont.pointSize() + 6);
painter->setFont(iconFont);
painter->setPen(option.palette.text().color());
QRect iconRect(L.bubbleRect.left() + kPadH, L.bubbleRect.top() + kPadTop, 24, 24);
if (dlState == 2)
painter->drawText(iconRect, Qt::AlignCenter, QString::fromUtf8("\xE2\x9C\x85"));
else
painter->drawText(iconRect, Qt::AlignCenter, QString::fromUtf8("\xE2\xAC\x87\xEF\xB8\x8F"));
QFont fileFont = option.font;
fileFont.setBold(true);
painter->setFont(fileFont);
painter->setPen(option.palette.text().color());
QString displayName = index.data(MsgFileDisplayNameRole).toString();
painter->drawText(L.textRect, Qt::AlignLeft | Qt::AlignVCenter, displayName);
QFont sizeFont = option.font;
sizeFont.setPointSize(sizeFont.pointSize() - 2);
painter->setFont(sizeFont);
painter->setPen(QColor(0x80, 0x80, 0x80));
qint64 fsz = index.data(MsgFileSizeRole).toLongLong();
QString sizeStr = formatFileSize(fsz);
QString stateStr = (dlState == 2) ? QString::fromUtf8("\xE2\x9C\x85") : (dlState == 1) ? QCoreApplication::translate("ChatGui",
"Downloading...") : QCoreApplication::translate("ChatGui",
"Download");
QString deliveryState = index.data(MsgDeliveryStateRole).toString();
if (!deliveryState.isEmpty()) stateStr = deliveryState;
QString info = sizeStr + " \xC2\xB7 " + stateStr;
int nameH = QFontMetrics(fileFont).height();
QRect sizeRect(L.textRect.left(), L.textRect.top() + nameH, L.textRect.width(), QFontMetrics(sizeFont).height());
painter->drawText(sizeRect, Qt::AlignLeft | Qt::AlignVCenter, info);
/* media bar (left edge, 3px) */
{ QColor barC = L.mediaBarColor == 2 ? QColor(0x40, 0xC0, 0x40) : L.mediaBarColor == 1 ? QColor(0xF0, 0xC0, 0x40) : QColor(0x80, 0x80, 0x80);
painter->setPen(Qt::NoPen); painter->setBrush(barC); painter->drawRect(L.mediaBarRect); }
/* progress bar (bottom edge, 3px) */
if (L.progressBarRect.isValid()) {
painter->setBrush(QColor(0x44, 0x44, 0x44));
painter->drawRoundedRect(L.progressBarRect, 2, 2);
int pw = (int)(L.progressBarRect.width() * L.progressFrac);
if (pw > 0) { painter->setBrush(QColor(0x33, 0x90, 0xEC)); painter->drawRoundedRect(QRect(L.progressBarRect.left(), L.progressBarRect.top(), pw, 3), 2, 2); }
}
painter->restore();
return;
}
auto *cv = qobject_cast<const ChatView *>(option.widget);
bool singleTextSel = false;
bool multiRowSel = false;
if (cv && cv->hasSelection()) {
if (cv->isRowSelected(index)) {
if (cv->selStart() == cv->selEnd()) {
singleTextSel = true;
} else {
multiRowSel = true;
}
}
}
drawBubble(painter, L, option);
if (multiRowSel) {
painter->save();
painter->setPen(Qt::NoPen);
painter->setBrush(QColor(0x33, 0x90, 0xEC, 40));
painter->drawRect(L.bubbleRect);
painter->restore();
}
if (!L.narrow)
drawAvatar(painter, L, index);
if (!L.isOutgoing) {
QFont authorFont = option.font;
authorFont.setBold(true);
painter->setFont(authorFont);
painter->setPen(QColor(0x33, 0x90, 0xEC));
painter->drawText(L.authorRect, Qt::AlignLeft | Qt::AlignVCenter,
index.data(MsgAuthorRole).toString());
}
painter->setPen(option.palette.text().color());
QFont textFont = option.font;
textFont.setPointSize(textFont.pointSize());
painter->setFont(textFont);
QString msgText = index.data(MsgTextRole).toString();
QString overlayText = msgText;
const bool hasLinks = !messageLinks(msgText).isEmpty();
if (hasLinks) {
QString display = msgText; display.replace('\n', QChar(0x2028));
QTextLayout layout(display, textFont);
const QString compactLink = index.data(MsgInviteLinkRole).toString();
layoutMessageText(layout, L.textRect.width(), compactLink);
overlayText = layout.text(); overlayText.replace(QChar(0x2028), '\n');
QVector<QTextLayout::FormatRange> selections;
if (singleTextSel && cv) {
auto cursorAt = [&](const QPoint& point) -> int {
for (int i = 0; i < layout.lineCount(); ++i) {
const auto line = layout.lineAt(i);
if (point.y() < line.y() + line.height()) return line.xToCursor(point.x());
}
return layout.text().length();
};
const auto& selection = cv->textSelection();
int start = cursorAt(selection.clickPos - L.textRect.topLeft());
int end = cursorAt(selection.dragPos - L.textRect.topLeft());
if (start > end) qSwap(start, end);
if (start == end && end < layout.text().length()) ++end;
int copyStart = start, copyEnd = end;
if (!compactLink.isEmpty()) {
const int linkStart = msgText.length() - compactLink.length();
if (end > linkStart) {
if (start >= linkStart) { start = linkStart; copyStart = linkStart; }
end = layout.text().length(); copyEnd = msgText.length();
}
}
const_cast<ChatView*>(cv)->setTextSelChars(copyStart, copyEnd);
QTextLayout::FormatRange range;
range.start = start; range.length = end - start; range.format.setBackground(QColor(0x33, 0x90, 0xEC, 60));
selections.append(range);
}
layout.draw(painter, L.textRect.topLeft(), selections);
} else painter->drawText(L.textRect, Qt::AlignLeft | Qt::TextWordWrap, msgText);
if (singleTextSel && cv && !hasLinks) {
const auto &ts = cv->textSelection();
QPoint localP1(ts.clickPos.x() - L.textRect.left(),
ts.clickPos.y() - L.textRect.top());
QPoint localP2(ts.dragPos.x() - L.textRect.left(),
ts.dragPos.y() - L.textRect.top());
int startChar, endChar;
calcTextSelection(textFont, msgText, L.textRect.width(),
localP1, localP2, startChar, endChar);
const_cast<ChatView *>(cv)->setTextSelChars(startChar, endChar);
drawTextSelectionHighlight(painter, textFont, msgText,
L.textRect.width(), L.textRect,
QColor(0x33, 0x90, 0xEC, 60),
startChar, endChar);
}
drawAnimatedEmojiOverlay(painter, textFont, overlayText,
L.textRect.width(), L.textRect);
drawStatusBar(painter, L, index, option);
painter->restore();
}
void MessageDelegate::drawQuote(QPainter *p, const Layout &L,
const QModelIndex &index,
const QStyleOptionViewItem &opt) const {
p->save();
p->setPen(Qt::NoPen);
QColor background = opt.palette.highlight().color(); background.setAlpha(28);
p->setBrush(background);
p->drawRoundedRect(L.quoteRect, 5, 5);
p->setPen(Qt::NoPen);
p->setBrush(opt.palette.highlight());
p->drawRect(L.quoteBarRect);
QFont boldFont = opt.font;
boldFont.setBold(true);
p->setFont(boldFont);
p->setPen(opt.palette.text().color());
p->drawText(L.quoteAuthorRect, Qt::AlignLeft | Qt::AlignVCenter,
QFontMetrics(boldFont).elidedText(index.data(MsgQuoteAuthorRole).toString(), Qt::ElideRight, L.quoteAuthorRect.width()));
p->setFont(opt.font);
p->setPen(opt.palette.text().color());
p->drawText(L.quoteTextRect, Qt::AlignLeft | Qt::AlignVCenter,
QFontMetrics(opt.font).elidedText(index.data(MsgQuoteTextRole).toString(), Qt::ElideRight, L.quoteTextRect.width()));
p->restore();
}
void MessageDelegate::drawBubble(QPainter *p, const Layout &L, const QStyleOptionViewItem &opt) const {
p->save();
p->setRenderHint(QPainter::Antialiasing);
QRectF b = L.bubbleRect;
qreal r = kRadius;
qreal t = kTail;
QPainterPath body;
body.addRoundedRect(b, r, r);
QPainterPath tail;
if (!L.isOutgoing) {
qreal tx = b.left();
qreal ty = b.bottom();
tail.moveTo(tx, ty - r);
tail.lineTo(tx - t, ty - t / 2.0);
tail.lineTo(tx + r, ty);
} else {
qreal tx = b.right();
qreal ty = b.bottom();
tail.moveTo(tx, ty - r);
tail.lineTo(tx + t, ty - t / 2.0);
tail.lineTo(tx - r, ty);
}
tail.closeSubpath();
QPainterPath combined = body.united(tail);
QColor bubbleFill;
if (!L.isOutgoing) {
QColor base = opt.palette.window().color();
bubbleFill = base.lighter(135);
} else {
QColor base = opt.palette.window().color();
bubbleFill = base.lighter(135);
bubbleFill.setRed(qMax(0, bubbleFill.red() - 10));
bubbleFill.setGreen(qMin(255, bubbleFill.green() + 5));
bubbleFill.setBlue(qMax(0, bubbleFill.blue() - 10));
}
p->setPen(Qt::NoPen);
p->setBrush(bubbleFill);
p->drawPath(combined);
p->restore();
}
void MessageDelegate::drawReactions(QPainter* p, const Layout& layout, const QStyleOptionViewItem& option) const {
p->save(); p->setRenderHint(QPainter::Antialiasing);
const QPointF origin = QRectF(layout.reactionHeart).center() - QPointF(10, 10);
QPainterPath heart;
heart.moveTo(origin + QPointF(12, 21));
heart.cubicTo(origin + QPointF(10.5, 19.5), origin + QPointF(3, 14.2), origin + QPointF(3, 8.5));
heart.cubicTo(origin + QPointF(3, 3), origin + QPointF(9, 2), origin + QPointF(12, 6));
heart.cubicTo(origin + QPointF(15, 2), origin + QPointF(21, 3), origin + QPointF(21, 8.5));
heart.cubicTo(origin + QPointF(21, 14.2), origin + QPointF(13.5, 19.5), origin + QPointF(12, 21));
heart.closeSubpath();
QTransform shrink; shrink.translate(origin.x(), origin.y()); shrink.scale(20.0 / 24, 20.0 / 24);
shrink.translate(-origin.x(), -origin.y()); heart = shrink.map(heart);
p->setBrush(Qt::NoBrush);
p->setPen(QPen(QColor(0, 0, 0, 153), 1.5 + 2.0 / p->device()->devicePixelRatioF(), Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
p->drawPath(heart);
p->setPen(QPen(Qt::white, 1.5, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); p->drawPath(heart);
for (const auto& chip : layout.reactionChips) {
p->setPen(Qt::NoPen); p->setBrush(chip.mine ? QColor(51, 144, 236, 100) : QColor(0, 0, 0, 40));
p->drawRoundedRect(chip.rect, 9, 9);
const QRect iconRect(chip.rect.left() + 5, chip.rect.top() + (chip.rect.height() - 18) / 2, 18, 18);
LottieIcon* icon = g_animatedEmojiMap.contains(chip.emoji) ? AnimTimer::instance()->icon(g_animatedEmojiMap[chip.emoji]) : nullptr;
QFont font = option.font; font.setPointSize(qMax(1, font.pointSize() - 1)); p->setFont(font);
if (icon && icon->isValid()) p->drawImage(iconRect, icon->renderFrame(icon->currentFrame(), QSize(18, 18)));
else { p->setPen(option.palette.text().color()); p->drawText(iconRect, Qt::AlignCenter, chip.emoji); }
font.setBold(chip.mine); p->setFont(font); p->setPen(option.palette.text().color());
p->drawText(chip.rect.adjusted(25, 0, -5, 0), Qt::AlignLeft | Qt::AlignVCenter, QString::number(chip.count));
}
QFont timeFont = option.font; timeFont.setPointSize(qMax(1, timeFont.pointSize() - 2)); p->setFont(timeFont);
p->setPen(QColor(128, 128, 128));
p->drawText(layout.reactionTime, Qt::AlignRight | Qt::AlignVCenter,
QFontMetrics(timeFont).elidedText(layout.reactionTimeText, Qt::ElideLeft, layout.reactionTime.width()));
p->restore();
}
void MessageDelegate::drawStatusBar(QPainter* p, const Layout& layout, const QModelIndex& index,
const QStyleOptionViewItem& option) const {
if (layout.reactionTime.isValid()) return;
p->save();
QFont font = option.font; font.setPointSize(qMax(1, font.pointSize() - 2)); p->setFont(font);
p->setPen(QColor(128, 128, 128));
QString text = index.data(MsgEditedRole).toBool() ? QCoreApplication::translate("ChatGui", "edited ") : QString();
text += index.data(MsgTimeRole).toString();
p->drawText(layout.statusRect, Qt::AlignRight | Qt::AlignVCenter, text);
p->restore();
}
void MessageDelegate::drawAvatar(QPainter *p, const Layout &L,
const QModelIndex &index) const {
QPixmap av = index.data(MsgAvatarRole).value<QPixmap>();
if (av.isNull()) return;
p->save();
p->setRenderHint(QPainter::Antialiasing);
QPainterPath clip;
clip.addEllipse(L.avatarRect);
p->setRenderHint(QPainter::SmoothPixmapTransform);
p->setClipPath(clip);
p->drawPixmap(L.avatarRect, av);
p->restore();
}
void MessageDelegate::startAutoPlay(const QString& filePath, float actualDurationSec, uint64_t playbackId) {
if (actualDurationSec <= 0) return;
ensureVoiceTimer();
VoicePlayState& st = s_voiceStates[filePath];
st.playbackId = playbackId;
st.playing = true;
st.paused = false;
st.durationSec = (actualDurationSec > 0) ? actualDurationSec : 1.0f;
st.startMs = QDateTime::currentMSecsSinceEpoch();
st.pausedPosSec = 0.0f;
st.displayFrac = 0.0f;
st.displayElapsed = 0.0f;
s_voiceTimer->start();
if (s_voiceView) s_voiceView->viewport()->update();
}
void MessageDelegate::stopAllAutoPlay() {
VoicePlayer::stop();
for (auto it = s_voiceStates.begin(); it != s_voiceStates.end(); ++it) {
it->playing = false; it->paused = false; it->loading = false;
}
}